<?php declare(strict_types=1);
namespace Sq\Entity\Schema\ORM;
use Carbon\Carbon;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Sq\GraphQL\Exception\SqGraphQLException;
use Sq\Service\Repository\ORM\UserRepository;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
#[ORM\UniqueConstraint(name: 'u_email', columns: ['u_email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
public const ROLE_USER = 'user';
public const ROLE_ADMIN = 'admin';
public const ROLE_MODERATOR = 'moderator';
public const VALID_ROLES = [
self::ROLE_USER,
self::ROLE_MODERATOR,
self::ROLE_ADMIN,
];
/**
*
* @var int|null
*/
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[ORM\Column(name: 'u_id', type: 'integer', nullable: false, options: ['unsigned' => true])]
private $id;
/**
* @deprecated We now get relationships from Organization->getMember(), and Organization->getOwner()
*/
#[ORM\OneToOne(targetEntity: LegacyAlphaMap::class, mappedBy: 'user', cascade: ['persist'])]
private $legacyAlphaMap;
#[ORM\OneToOne(targetEntity: LeadDynoAffiliate::class, mappedBy: 'user', cascade: ['persist'])]
private $leadDynoAffiliate;
/**
* @var string
*/
#[ORM\Column(name: 'u_email', type: 'binary_aes_encrypted', nullable: false)]
private $email;
/**
* @var string
*/
#[ORM\Column(name: 'u_password_hash', type: 'string', nullable: false)]
private $password;
/**
* @var string|null
*/
#[ORM\Column(name: 'u_role', type: 'string', nullable: true)]
private $role = self::ROLE_USER;
/**
* @var string
*/
#[ORM\Column(name: 'u_first_name', type: 'string', nullable: false)]
private $firstName;
/**
* @var string
*/
#[ORM\Column(name: 'u_timezone', type: 'string', length: 42, nullable: false, options: ['default' => 'UTC'])]
private $timezone = 'UTC';
/**
* @var UserSettings
*/
#[ORM\OneToOne(targetEntity: UserSettings::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private $settings;
/**
* @var Collection<UserOrganizationAssignment>
*/
#[ORM\OneToMany(targetEntity: UserOrganizationAssignment::class, mappedBy: 'user', fetch: 'EAGER', cascade: ['persist'], orphanRemoval: true)]
private $organizationAssignments;
/**
*
* @var Collection<Workspace>
*/
#[ORM\ManyToMany(targetEntity: Workspace::class, inversedBy: 'users', fetch: 'EAGER', cascade: ['persist'])]
#[ORM\JoinTable(name: 'user_workspace_assignments', joinColumns: [new ORM\JoinColumn(name: 'uwa_u_id', referencedColumnName: 'u_id')], inverseJoinColumns: [new ORM\JoinColumn(name: 'uwa_ws_id', referencedColumnName: 'ws_id')])]
private $workspaces;
/**
* @var int|null
*/
#[ORM\Column(name: 'u_home_design', type: 'integer', nullable: true, options: ['unsigned' => true])]
private $homeDesign;
/**
* @var \DateTimeInterface
*/
#[ORM\Column(name: 'u_date_joined', type: 'datetime', nullable: false)]
private $dateJoined;
/**
* @var \DateTimeInterface
*/
#[ORM\Column(name: 'u_last_login', type: 'datetime', nullable: false)]
private $lastLogin;
/**
* @var \DateTimeInterface|null
*/
#[ORM\Column(name: 'u_first_visit', type: 'datetime', nullable: true)]
private $firstVisit;
/**
* @var int|null
*/
#[ORM\Column(name: 'u_mautic_id', type: 'integer', nullable: true, options: ['unsigned' => true])]
private $mauticId;
/**
* @var bool
*/
#[ORM\Column(name: 'u_email_bounced', type: 'boolean', nullable: false, options: ['default' => false])]
private $emailBounced = false;
#[ORM\ManyToOne(targetEntity: SiteNews::class)]
#[ORM\JoinColumn(name: 'u_last_seen_site_news', referencedColumnName: 'site_news_id', nullable: true)]
private $lastSeenSiteNews;
/**
* @var Collection|UserOnboardingStep[]
*/
#[ORM\OneToMany(targetEntity: UserOnboardingStep::class, mappedBy: 'user', orphanRemoval: true, cascade: ['persist'])]
private $onboardingStepsSeen;
/**
* @var bool
*/
#[ORM\Column(name: 'u_onboarding_completed', type: 'boolean', nullable: false, options: ['default' => false])]
private $onboardingCompleted = false;
/**
* @var \DateTimeInterface|null
*/
#[ORM\Column(name: 'u_onboarding_completed_datetime', type: 'datetime', nullable: true)]
private $onboardingCompletedDatetime;
/**
* @var int
*/
#[ORM\Column(name: 'u_onboarding_version', type: 'integer', nullable: false, options: ['default' => 1])]
private $onboardingVersion;
#[ORM\OneToMany(targetEntity: AndroidApp::class, mappedBy: 'user', cascade: ['persist'])]
private $androidApps;
#[ORM\OneToMany(targetEntity: IosApp::class, mappedBy: 'user', cascade: ['persist'])]
private $iosApps;
/**
* @var Collection<FacebookToken>
*/
#[ORM\ManyToMany(targetEntity: FacebookToken::class, mappedBy: 'users')]
private $facebookTokens;
/**
* @var Collection<GoogleToken>
*/
#[ORM\ManyToMany(targetEntity: GoogleToken::class, mappedBy: 'users')]
private $googleTokens;
/**
* @var Collection<LinkedInToken>
*/
#[ORM\ManyToMany(targetEntity: LinkedInToken::class, mappedBy: 'users')]
private $linkedInTokens;
public function __construct(string $email, string $firstName, int $onboardingVersion = 1)
{
$this->email = $email;
// See $this->setPassword().
$this->password = '';
$this->firstName = $firstName;
$this->dateJoined = new \DateTimeImmutable;
$this->lastLogin = clone $this->dateJoined;
$this->timezone = 'UTC';
$this->onboardingVersion = $onboardingVersion;
$this->settings = new UserSettings($this);
$this->organizationAssignments = new ArrayCollection;
$this->workspaces = new ArrayCollection;
$this->onboardingStepsSeen = new ArrayCollection;
$this->androidApps = new ArrayCollection();
$this->iosApps = new ArrayCollection();
$this->facebookTokens = new ArrayCollection();
$this->googleTokens = new ArrayCollection();
$this->linkedInTokens = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
/** @deprecated (delete when upgraded to Symfony 6+) */
public function getUsername(): string
{
return $this->getUserIdentifier();
}
public function getUserIdentifier(): string
{
// Return the user ID because that's how we identify users in auth (Paseto) tokens.
return (string) $this->getId();
}
public function getPassword(): string
{
return $this->password;
}
public function hasPasswordSet(): bool
{
return !empty($this->password);
}
/**
* Set Raw Password Hash
* Do *NOT* enter a plaintext password here! Setting the password requires the external password encoder service
* which encodes according to the per-UserInterface security configuration. Please use the following for both entity
* construction and this method.
*
* [Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface $passwordHashers]
* $user->setPassword($passwordHasher->hashPassword($user, 'the_new_password'));
*/
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/** @deprecated (delete when upgrading to Symfony 6+) */
public function getSalt(): ?string
{
return null;
}
public function eraseCredentials(): void
{
}
public function getRole(): string
{
return $this->role ?: self::ROLE_USER;
}
/**
* @internal
*
* @deprecated
*
* Not actually deprecated, but this is specifically for Symfony usage.
* This is for app roles (admin) and has *nothing* to do with the user-organization assignment roles.
*/
public function getRoles(): iterable
{
return ['ROLE_' . strtoupper($this->getRole())];
}
public function setRole(string $role): self
{
if (!in_array($role, static::VALID_ROLES))
{
throw new \Exception(sprintf('Cannot add role "%s"; not a valid role.', $role));
}
$this->role = $role;
return $this;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getTimezone(): \DateTimeZone
{
return new \DateTimeZone($this->timezone);
}
public function setTimezone(\DateTimeZone $timezone): self
{
$this->timezone = $timezone->getName();
return $this;
}
public function getSettings(): UserSettings
{
return $this->settings;
}
public function getOrganizations(): Collection
{
return $this->organizationAssignments->map(function (UserOrganizationAssignment $assignment): Organization
{
return $assignment->getOrganization();
});
}
public function getOwnedOrganization(): Organization
{
$orgs = $this->getOrganizations();
foreach ($orgs as $org)
{
if ($this->getRoleWithinOrganization($org) === UserOrganizationAssignment::ROLE_OWNER)
{
return $org;
}
}
throw new \LogicException('No owned organization found for User ID ' . $this->getId());
}
public function getOrganizationsWithRoles(): \SplObjectStorage
{
$map = new \SplObjectStorage;
/** @var UserOrganizationAssignment $assignment */
foreach ($this->organizationAssignments as $assignment)
{
$map->attach($assignment->getOrganization(), $assignment->getRole());
}
return $map;
}
public function getRoleWithinOrganization(Organization $organization): ?string
{
return $this->getOrganizationsWithRoles()[$organization] ?? null;
}
public function getOrganizationAssignments(): Collection
{
return $this->organizationAssignments;
}
public function getOrganizationAssignment(Organization $organization): ?UserOrganizationAssignment
{
$assignments = $this->organizationAssignments->filter(function (UserOrganizationAssignment $assignment) use ($organization)
{
return $assignment->getOrganization() === $organization;
});
return $assignments->count() > 0 ? $assignments->first() : null;
}
public function isInOrganization(Organization $organization): bool
{
return $this->getOrganizations()->contains($organization);
}
public function addToOrganization(Organization $organization, string $role): self
{
// Make sure we don't accidentally add the user to the same org twice.
$alreadyAssigned = $this->organizationAssignments->filter(function (UserOrganizationAssignment $assignment) use ($organization)
{
return $organization === $assignment->getOrganization();
});
if ($alreadyAssigned->count() > 0)
{
return $this;
}
$assignment = new UserOrganizationAssignment($this, $organization, $role);
$this->organizationAssignments->add($assignment);
$organization->getUserAssignments()->add($assignment);
return $this;
}
public function removeFromOrganization(Organization $organization): self
{
/** @var UserOrganizationAssignment $assignment */
foreach ($this->organizationAssignments as $assignment)
{
if ($assignment->getOrganization()->getId() === $organization->getId())
{
$this->organizationAssignments->removeElement($assignment);
$organization->getUserAssignments()->removeElement($assignment);
}
}
/** @var Workspace $workspace */
foreach ($this->workspaces as $workspace)
{
if ($workspace->getOrganization()->getId() === $organization->getId())
{
$this->workspaces->removeElement($workspace);
$workspace->getUsers()->removeElement($workspace);
}
}
return $this;
}
public function getWorkspaces(bool $includeDeleted = false): Collection
{
if ($includeDeleted)
{
return $this->workspaces;
}
return $this->workspaces->filter(
fn (Workspace $workspace): bool => !$workspace->isDeleted()
);
}
public function getWorkspacesInOrganization(Organization $organization, bool $includeDeleted = false): Collection
{
return $this->workspaces->filter(function (Workspace $workspace) use ($organization, $includeDeleted): bool
{
return $workspace->getOrganization()->getId() === $organization->getId()
&& ($includeDeleted || !$workspace->isDeleted());
});
}
public function isInWorkspace(Workspace $workspace): bool
{
return $this->getWorkspaces()->contains($workspace);
}
public function addToWorkspace(Workspace $workspace): self
{
if (!$this->getOrganizations()->contains($workspace->getOrganization()))
{
throw new \Exception("User does not belong to that workspace's organization.");
}
if (!$this->workspaces->contains($workspace))
{
$this->workspaces->add($workspace);
$workspace->getUsers()->add($this);
}
return $this;
}
public function removeFromWorkspace(Workspace $workspace): self
{
if ($this->workspaces->contains($workspace))
{
if (count($this->getWorkspacesInOrganization($workspace->getOrganization())) <= 1)
{
throw SqGraphQLException::notEnoughWorkspaces();
}
$this->workspaces->removeElement($workspace);
$workspace->getUsers()->removeElement($this);
}
return $this;
}
public function getMobileApps(bool $onlyConnected = true): Collection
{
$allApps = new ArrayCollection(
array_merge($this->iosApps->toArray(), $this->androidApps->toArray())
);
return $allApps->filter(function (MobileAppInterface $app) use ($onlyConnected)
{
return !$onlyConnected || $app->isConnected();
});
}
public function getAndroidApps(bool $onlyConnected = true): Collection
{
$allApps = $this->getMobileApps($onlyConnected);
return $allApps->filter(function (MobileAppInterface $app)
{
return $app instanceof AndroidApp;
});
}
public function getIosApps(bool $onlyConnected = true): Collection
{
$allApps = $this->getMobileApps($onlyConnected);
return $allApps->filter(function (MobileAppInterface $app)
{
return $app instanceof IosApp;
});
}
public function addMobileApp(MobileAppInterface $app): self
{
if ($app instanceof IosApp)
{
if (!$this->iosApps->contains($app))
{
$this->iosApps->add($app);
}
}
elseif ($app instanceof AndroidApp)
{
if (!$this->androidApps->contains($app))
{
$this->androidApps->add($app);
}
}
return $this;
}
public function removeMobileApp(MobileAppInterface $app): self
{
if ($app instanceof IosApp)
{
if ($this->iosApps->contains($app))
{
$this->iosApps->removeElement($app);
}
}
elseif ($app instanceof AndroidApp)
{
if ($this->androidApps->contains($app))
{
$this->androidApps->removeElement($app);
}
}
return $this;
}
public function getHomeDesign(): ?int
{
return $this->homeDesign;
}
public function setHomeDesign(?int $homeDesign): self
{
$this->homeDesign = $homeDesign;
return $this;
}
public function getDateJoined(): \DateTimeInterface
{
return clone $this->dateJoined;
}
public function setDateJoined(\DateTimeInterface $dateJoined): self
{
$this->dateJoined = clone $dateJoined;
return $this;
}
public function getLastLogin(): \DateTimeInterface
{
return clone $this->lastLogin;
}
public function setLastLogin(\DateTimeInterface $lastLogin): self
{
$this->lastLogin = clone $lastLogin;
return $this;
}
public function getFirstVisit(): ?\DateTimeInterface
{
return $this->firstVisit !== null
? clone $this->firstVisit
: null;
}
public function setFirstVisit(?\DateTimeInterface $firstVisit): self
{
$this->firstVisit = $firstVisit !== null ? clone $firstVisit : null;
return $this;
}
public function getMauticId(): ?int
{
return $this->mauticId;
}
public function setMauticId(?int $mauticId): self
{
$this->mauticId = $mauticId;
return $this;
}
public function hasEmailBounced(): bool
{
return $this->emailBounced;
}
public function setEmailBounced(bool $emailBounced): self
{
$this->emailBounced = $emailBounced;
return $this;
}
public function getLastSeenSiteNews(): ?SiteNews
{
return $this->lastSeenSiteNews;
}
public function setLastSeenSiteNews(?SiteNews $lastSeenSiteNews): self
{
$this->lastSeenSiteNews = $lastSeenSiteNews;
return $this;
}
/**
* Get LeadDyno Affiliate object if this member has an affiliate account on LeadDyno.
*/
public function getLeadDynoAffiliate(): ?LeadDynoAffiliate
{
return $this->leadDynoAffiliate;
}
/**
* Set a member as being a LeadDynoAffiliate.
*/
public function setLeadDynoAffiliate(?LeadDynoAffiliate $affiliate): self
{
$this->leadDynoAffiliate = $affiliate;
// set (or unset) the owning side of the relation if necessary
$newUser = null === $affiliate ? null : $this;
if ($affiliate->getUser() !== $newUser)
{
$affiliate->setUser($newUser);
}
return $this;
}
public function getOnboardingStepsSeen(): Collection
{
return $this->onboardingStepsSeen;
}
public function markOnboardingStepAsSeen(UserOnboardingStep|string $step): self
{
$step = is_string($step) ? (new UserOnboardingStep($this, $step)) : $step;
// Don't add it to the collection if we already have it.
foreach ($this->onboardingStepsSeen as $stepSeen)
{
if ($stepSeen->getName() === $step->getName())
{
return $this;
}
}
$this->onboardingStepsSeen->add($step);
return $this;
}
public function isOnboardingCompleted(): bool
{
return $this->onboardingCompleted;
}
public function getOnboardingCompletedDatetime(): ?\DateTimeInterface
{
return $this->onboardingCompletedDatetime;
}
public function completeOnboarding(): self
{
$this->onboardingCompleted = true;
$this->onboardingCompletedDatetime = Carbon::now();
return $this;
}
public function markAllOnboardingTourTipsSeen(): self
{
$tourTips = [
'v1_tourtip_categories_articles',
'v1_tourtip_categories_demo',
'v1_tourtip_posting_plan_demo_profiles',
'v1_tourtip_posting_plan_demo_timeslot',
'v1_tourtip_queue_manage_post',
'v1_tourtip_queue_new_post',
'v1_tourtip_post_editor_select_category',
'v1_tourtip_post_editor_customize_per_profile',
'v1_tourtip_post_editor_link',
'v1_tourtip_post_editor_post_timing',
'v1_tourtip_post_editor_post_preview',
'v1_tourtip_profiles_add_your_own',
'v1_tourtip_profiles_demo',
];
foreach ($tourTips as $tourTip)
{
$this->markOnboardingStepAsSeen($tourTip);
}
return $this;
}
/* Also need to remove all user_onboarding_steps */
public function resetOnboarding(): self
{
$this->onboardingCompleted = false;
$this->onboardingCompletedDatetime = null;
return $this;
}
public function getOnboardingVersion(): int
{
return $this->onboardingVersion;
}
public function wasLegacySignup(): bool
{
return $this->getOwnedOrganization()->getLegacyMember()->wasLegacySignup();
}
public function canRollbackToLegacy(): bool
{
return $this->getOwnedOrganization()->getLegacyMember()->canRollbackToLegacy();
}
/**
* @return FALSE if user was never a migrated legacy member.
* @return FALSE if user was a migrated legacy member but is currently in the overhaul.
* @return TRUE if user was a migrated legacy member and was rolled back to legacy.
*/
public function isRolledBackToLegacy(): bool
{
$legacyMember = $this->getOwnedOrganization()->getLegacyMember();
return $legacyMember->wasLegacySignup() && !$legacyMember->isInOverhaul();
}
/**
* Get all FacebookTokens associated with this user.
*
* @param bool $onlyActive If true, filters out deleted tokens
* @return Collection<FacebookToken>
*/
public function getFacebookTokens(bool $onlyActive = true): Collection
{
if (!$onlyActive)
{
return $this->facebookTokens;
}
return $this->facebookTokens->filter(function (FacebookToken $token): bool
{
return !$token->isDeleted();
});
}
/**
* Get all GoogleTokens associated with this user.
*
* @param bool $onlyActive If true, filters out deleted tokens
* @return Collection<GoogleToken>
*/
public function getGoogleTokens(bool $onlyActive = true): Collection
{
if (!$onlyActive)
{
return $this->googleTokens;
}
return $this->googleTokens->filter(function (GoogleToken $token): bool
{
return !$token->isDeleted();
});
}
/**
* Get all LinkedInTokens associated with this user.
*
* @param bool $onlyActive If true, filters out deleted tokens
* @return Collection<LinkedInToken>
*/
public function getLinkedInTokens(bool $onlyActive = true): Collection
{
if (!$onlyActive)
{
return $this->linkedInTokens;
}
return $this->linkedInTokens->filter(function (LinkedInToken $token): bool
{
return !$token->isDeleted();
});
}
/**
* Get all social tokens (Google, Facebook, LinkedIn) for this user.
*
* @param bool $onlyActive If true, filters out deleted/error tokens
* @return array{google: Collection<GoogleToken>, facebook: Collection<FacebookToken>, linkedin: Collection<LinkedInToken>}
*/
public function getAllSocialTokens(bool $onlyActive = true): array
{
return [
'google' => $this->getGoogleTokens($onlyActive),
'facebook' => $this->getFacebookTokens($onlyActive),
'linkedin' => $this->getLinkedInTokens($onlyActive),
];
}
}