<?php
declare(strict_types=1);
namespace App\Entity;
use App\Contracts\User\Status;
use App\Contracts\User\UserInterface;
use DateTime;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\DiscriminatorColumn;
use Doctrine\ORM\Mapping\DiscriminatorMap;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\HasLifecycleCallbacks;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Index;
use Doctrine\ORM\Mapping\InheritanceType;
use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\ORM\Mapping\OneToOne;
use Doctrine\ORM\Mapping\PrePersist;
use Doctrine\ORM\Mapping\PreUpdate;
use Doctrine\ORM\Mapping\UniqueConstraint;
use LogicException;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\UserInterface as SymfonyUserInterface;
#[Entity]
#[Index(columns: ['language'], name: 'idx_user_language')]
#[UniqueConstraint('unique_email', ['email'])]
#[UniqueEntity('email', 'Email is already used')]
#[HasLifecycleCallbacks]
#[InheritanceType('JOINED')]
#[DiscriminatorColumn(name: 'role', type: 'string')]
#[DiscriminatorMap([
Administrator::ROLE_NAME => Administrator::class,
Creator::ROLE_NAME => Creator::class,
Sponsor::ROLE_NAME => Sponsor::class,
Manager::ROLE_NAME => Manager::class,
])]
class User implements UserInterface, EquatableInterface
{
public const ROLE_NAME = null;
public const SOFT_DELETE_DAYS = 30;
/** @var string */
public const BIRTHDAY_FORMAT = 'm/d/Y';
/** @var string */
private const AVATAR_FILE_NAME_TEMPLATE = 'user.%d.avatar';
#[Id]
#[GeneratedValue]
#[Column(type: 'bigint', options: ['unsigned' => true])]
private ?int $id = null;
#[Column(name: 'status', type: 'smallint')]
private int $status = Status::CREATED;
#[Column(name: 'email', type: 'string', nullable: true)]
private ?string $email = null;
#[Column(name: 'phone_number', type: 'bigint', nullable: true, options: ['unsigned' => true])]
private ?int $phoneNumber = null;
#[Column(name: 'first_name', type: 'string', nullable: true)]
private ?string $firstName = null;
#[Column(name: 'last_name', type: 'string', nullable: true)]
private ?string $lastName = null;
/**
* Primary posting language as an ISO-639-1 code (e.g. 'en', 'es', 'pt'), derived from the mode of
* the creator's recent post textLanguage (profile language fallback). Stored on the profile's user
* row (creator.id === user.id). Per-profile only — not shared across owning-account peers.
*/
#[Column(type: 'string', length: 8, nullable: true)]
private ?string $language = null;
#[Column(type: 'datetime', nullable: true)]
private ?DateTime $blockedUntil;
#[Column(type: 'datetime', nullable: false)]
private DateTime $createdAt;
#[Column(type: 'datetime', nullable: true)]
private ?DateTime $updatedAt = null;
#[Column('deleted_at', 'datetime', nullable: true)]
private ?DateTime $deletedAt = null;
#[OneToOne(mappedBy: 'user', targetEntity: UserLocationPermission::class)]
private ?UserLocationPermission $locationPermissionTelemetry = null;
#[OneToOne(mappedBy: 'user', targetEntity: PaymentBalance::class)]
private ?PaymentBalance $paymentBalance = null;
#[OneToOne(mappedBy: 'user', targetEntity: Referral::class)]
private ?Referral $referral = null;
#[OneToMany(mappedBy: 'referredBy', targetEntity: Referral::class)]
private Collection $referrals;
#[PrePersist]
public function prePersist(): void
{
$this->createdAt = new DateTime();
}
#[PreUpdate]
public function preUpdate(): void
{
$this->updatedAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
/**
* Security tokens serialize the user into the session on EVERY request. Serializing the
* full entity drags along any initialized Doctrine relations (Manager::campaigns ->
* activities -> creators ...): observed 16MB+ session payloads (Jun 11 2026) that
* overflowed session storage and silently signed managers out — see
* docs/signouts-approval-dropped-timeout.md. Persist an identity shell only; the
* security ContextListener reloads the full entity from the user provider on every
* request, and isEqualTo() keeps the refreshed token valid.
*/
public function __serialize(): array
{
return [
'id' => $this->id,
'email' => $this->email,
'status' => $this->status,
];
}
public function __unserialize(array $data): void
{
// Accept both the identity-shell format and the legacy full-entity format (sessions
// written before this change carry PHP's mangled "\0Class\0prop" keys for every
// property) so already-authenticated users survive the deploy without a logout.
$legacy = "\0" . self::class . "\0";
$this->id = $data['id'] ?? $data[$legacy . 'id'] ?? null;
$this->email = $data['email'] ?? $data[$legacy . 'email'] ?? null;
$this->status = $data['status'] ?? $data[$legacy . 'status'] ?? Status::CREATED;
}
/**
* Used by the security layer after refreshing the user from the provider. Identity (same
* concrete class + id) is sufficient — and required: the serialized shell intentionally
* carries no password (subclass typed $password properties are uninitialized on it), so
* Symfony's default password/salt comparison must never run.
*/
public function isEqualTo(SymfonyUserInterface $user): bool
{
return $user instanceof static
&& $this->getId() !== null
&& $user->getId() === $this->getId();
}
public function getUsername(): string
{
return $this->email ?? (string)$this->id;
}
public function getFullname(): string
{
$fullName = trim(sprintf('%s %s', $this->firstName, $this->lastName));
return $fullName === '' ? $this->getUsername() : $fullName;
}
public function getPassword(): string
{
return '';
}
public function getSalt()
{
return null;
}
public function getUserIdentifier(): string
{
return (string)$this->getId();
}
public function eraseCredentials()
{
// TODO: Implement eraseCredentials() method.
}
public function getRoles(): array
{
if (static::ROLE_NAME === null) {
throw new LogicException('Undefined role name.');
}
return [static::ROLE_NAME];
}
public function isFinishedRegistration(): bool
{
return $this->checkStatus(...Status::FINISHED_LIST);
}
public function isFirstOpen(): bool
{
return !$this->checkStatus(Status::FIRST_OPEN);
}
public function firstOpen(): void
{
$this->status |= Status::FIRST_OPEN;
}
public function isEmailVerified(): bool
{
return $this->checkStatus(Status::EMAIL_VERIFIED);
}
public function isPhoneVerified(): bool
{
return $this->checkStatus(Status::PHONE_VERIFIED);
}
public function verifyPhone(): static
{
$this->status |= Status::PHONE_VERIFIED;
return $this;
}
public function verifyEmail(): static
{
$this->status |= Status::EMAIL_VERIFIED;
return $this;
}
private function checkStatus(int ...$statusList): bool
{
foreach ($statusList as $status) {
if (($this->status & $status) === 0) {
return false;
}
}
return true;
}
public function setFirstName(string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function setLastName(string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function name(string $firstName, string $lastName): self
{
$this->setFirstName($firstName);
$this->setLastName($lastName);
return $this;
}
public function baseInfo(string $firstName, string $lastName): self
{
$this->firstName = $firstName;
$this->lastName = $lastName;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function getLastName(): ?string
{
return $this->lastName;
}
/**
* @internal Doctrine-mapped field; use {@see Creator::getLanguage()} / {@see Creator::setLanguage()}.
*/
protected function getLanguageValue(): ?string
{
return $this->language;
}
/**
* @internal Doctrine-mapped field; use {@see Creator::setLanguage()}.
*/
protected function setLanguageValue(?string $language): static
{
$this->language = $language;
return $this;
}
public function setPhoneNumber(?string $phoneNumber): self
{
if ($phoneNumber !== null) {
$phoneNumber = (int)preg_replace('/[^\d]/', '', $phoneNumber);
if ($this->isPhoneVerified() && $this->phoneNumber !== $phoneNumber) {
$this->status ^= Status::PHONE_VERIFIED;
}
}
$this->phoneNumber = $phoneNumber;
return $this;
}
public function getPhoneNumber(): ?string
{
if ($this->phoneNumber === null) {
return null;
}
return sprintf('+%d', preg_replace('/[^\d]/', '', (string)($this->phoneNumber ?? '')));
}
public function setEmail(?string $email): self
{
if ($email !== null && $this->isEmailVerified() && $this->email !== $email) {
$this->status ^= Status::EMAIL_VERIFIED;
}
$this->email = $email;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function getAvatarFileName(): string
{
return sprintf(self::AVATAR_FILE_NAME_TEMPLATE, $this->getId());
}
public function getDeletedAt(): ?DateTime
{
return $this->deletedAt;
}
public function getBlockedUntil(): ?DateTime
{
return $this->blockedUntil;
}
public function setDeletedAt(bool $isDeleted = true): self
{
$this->deletedAt = $isDeleted ? new DateTime() : null;
return $this;
}
public function isDeleted(): bool
{
return $this->getDeletedAt() instanceof DateTime;
}
public function isSoftDeleted(): bool
{
if (!$this->isDeleted()) {
return false;
}
$now = new DateTimeImmutable();
$interval = $now->diff($this->getDeletedAt());
return $interval->days < self::SOFT_DELETE_DAYS;
}
public function block(?DateTime $blockedUntil = null): static
{
if (!$this->checkStatus(Status::BLOCKED)) {
$this->status |= Status::BLOCKED;
$this->blockedUntil = $blockedUntil;
}
return $this;
}
public function unblock(): static
{
$this->status ^= Status::BLOCKED;
$this->blockedUntil = null;
return $this;
}
public function isBlocked(): bool
{
return $this->checkStatus(Status::BLOCKED)
&& ($this->blockedUntil === null || $this->blockedUntil > new DateTime());
}
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
public function getUpdatedAt(): ?DateTime
{
return $this->updatedAt;
}
public function isRole(string $role): bool
{
return in_array($role, $this->getRoles(), true);
}
public function setPaymentBalance(PaymentBalance $paymentBalance): static
{
$this->paymentBalance = $paymentBalance;
return $this;
}
public function getPaymentBalance(): ?PaymentBalance
{
return $this->paymentBalance;
}
public function getReferral(): ?Referral
{
return $this->referral;
}
public function getReferrals(): Collection
{
return $this->referrals;
}
public function getLocationPermissionTelemetry(): ?UserLocationPermission
{
return $this->locationPermissionTelemetry;
}
public function setLocationPermissionTelemetry(?UserLocationPermission $locationPermissionTelemetry): static
{
$this->locationPermissionTelemetry = $locationPermissionTelemetry;
return $this;
}
}