<?phpnamespace App\Entity\Core;use App\Entity\Cvs\Candidates;use App\Repository\Core\UsersRepository;use Doctrine\DBAL\Types\Types;use Doctrine\ORM\Mapping as ORM;use Symfony\Component\Security\Core\User\UserInterface;use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;use Symfony\Component\Validator\Constraints as Assert;use Vich\UploaderBundle\Mapping\Annotation as Vich;use Symfony\Component\HttpFoundation\File\File;use Symfony\Component\HttpFoundation\File\UploadedFile;use Vich\UploaderBundle\Entity\File as EmbeddedFile;use ApiPlatform\Core\Annotation\ApiResource;use Symfony\Component\Serializer\Annotation\Groups;use Symfony\Component\Serializer\Annotation\SerializedName;use Serializable;use DateTimeImmutable;use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;use Vich\UploaderBundle\Templating\Helper\UploaderHelper;/** * @ORM\Entity(repositoryClass=UsersRepository::class) * @ORM\Table(name="users") * @ORM\HasLifecycleCallbacks() * @Vich\Uploadable * * @ApiResource( * normalizationContext={"groups"={"user:read"}}, * denormalizationContext={"groups"={"user:write"}} * ) */class Users implements UserInterface, PasswordAuthenticatedUserInterface, \Serializable{ /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") * * @Groups("user:read") */ private $id; /** * @var datetime * * @ORM\Column(name="createdAt", type="datetime", nullable=true) * * @Groups("user:read") */ private $createdAt; /** * @var datetime * * @ORM\Column(name="updatedAt", type="datetime", nullable=true) */ private $updatedAt; /** * @var datetime * * @ORM\Column(name="last_login", type="datetime", nullable=true) */ private $lastLogin; /** * @ORM\Column(type="string", length=180, unique=true) * @Groups("user:read") */ private $email; /** * @ORM\Column(type="string", length=180, nullable=true) * @Groups("user:read") */ private $name; /** * @ORM\Column(type="string", length=180, nullable=true) * @Groups("user:read") */ private $lastname; /** * @ORM\Column(type="json") */ private $roles = []; /** * @var string The hashed password * @ORM\Column(type="string") */ private $password; /** * @ORM\Column(type="string", length=180, nullable=true) */ private $username; /** * @ORM\Column(type="boolean", length=180, nullable=true) */ private $first; /** * @ORM\Column(type="boolean", length=180, nullable=true) */ private $enabled; /** * @ORM\Column(type="boolean", length=11, nullable=true) */ private $verification; /** * @var string * * @ORM\Column(name="premium", type="boolean", nullable=false) */ private $premium; /* ══════════════════════════════════════════════════════════════════════ LE QUOTA DE RÉDACTION PAR IA ⚠️ DEUX COLONNES, PAS UNE TABLE À PART. Le compteur vivait dans une table du site vitrine (`wr_ia_offre_usage`). Deux défauts : · le quota d'un COMPTE était stocké là où le compte n'existe pas — l'utilisateur vit ici, dans l'API ; · l'application mobile et tout autre client passant par l'API n'auraient pas été décomptés du tout. Deux colonnes sur l'utilisateur suffisent : un compteur et le jour auquel il se rapporte. ⚠️ LE JOUR EST STOCKÉ, PAS DÉDUIT. Sans lui, il faudrait une purge nocturne pour remettre les compteurs à zéro — un travail planifié de plus, qui tombe en panne sans que personne ne le voie, et le quota ne se réinitialise plus. En comparant la date stockée à celle du jour, la remise à zéro est implicite : elle a lieu au premier usage suivant. ══════════════════════════════════════════════════════════════════════ */ /** * @var int * * @ORM\Column(name="ia_offre_count", type="smallint", nullable=false, options={"default": 0, "unsigned": true}) */ private $iaOffreCount = 0; /** * @var \DateTimeInterface|null * * @ORM\Column(name="ia_offre_day", type="date", nullable=true) */ private $iaOffreDay; /** * ⚠️ UN PLAFOND PROPRE À CE COMPTE, OU RIEN. * * `NULL` veut dire « suit le défaut » — 2 sans abonnement, 100 avec. * C'est la même convention que `share_quota_per_day`, et elle mérite * d'être la même partout : deux quotas voisins qui traitent * différemment une valeur vide seraient un piège. * * Une valeur ici l'emporte, abonnement ou pas. Elle sert au geste * commercial — un client qui lance une campagne et a besoin de plus * que son forfait pour deux semaines. * * `0` est une valeur valide et signifie « aucune rédaction ». Ce * n'est PAS la même chose que vide. * * @var int|null * * @ORM\Column(name="ia_offre_quota", type="integer", nullable=true, options={"comment":"Plafond de rédactions IA par jour (NULL = selon premium)"}) */ private $iaOffreQuota; /** * Le moteur de recherche de candidats est-il ouvert à ce compte ? * * ⚠️ DEUX VERROUS, ET IL FAUT LES DEUX (option A). * * Accès = `premium` ET `searchEnabled`. * * `premium` est le droit COMMERCIAL : il s'achète et il expire. * `searchEnabled` est l'octroi EFFECTIF, posé compte par compte * depuis le back-office. * * Les deux parce qu'ils ne disent pas la même chose : un abonnement * qui s'arrête doit couper l'accès automatiquement (d'où `premium`), * et un abonnement qui démarre ne doit pas ouvrir la recherche sans * qu'on l'ait décidé (d'où le drapeau). * * ⚠️ Conséquence à assumer : un client premium sans le drapeau * n'aura PAS la recherche et écrira au support. C'est le prix de ce * modèle — le back-office doit donc afficher les deux états côte à * côte pour que le diagnostic prenne trois secondes. * * Faux à l'inscription. Personne ne l'obtient par défaut. * * @var bool|null * * @ORM\Column(name="search_enabled", type="boolean", nullable=true, options={"default":0,"comment":"Moteur de recherche candidats ouvert à ce compte"}) */ private $searchEnabled; /** * QUAND le recruteur a demandé l'accès au moteur de recherche. * * ⚠️ LA DEMANDE VIT ICI, PAS DANS LA BASE DU SITE VITRINE. * * Elle y était (liste d'attente locale) : le backoffice de l'API, * qui seul peut ouvrir l'accès (`searchEnabled`), ne la voyait pas. * Une demande et la décision qui y répond doivent être au même * endroit. Null = jamais demandé, ou demande refusée. * * @ORM\Column(name="search_requested_at", type="datetime", nullable=true, options={"comment":"Demande d'accès au moteur de recherche"}) */ private $searchRequestedAt; /** * Le quota d'envois de CV anonyme, par 24 h glissantes. * * ⚠️ NULL N'EST PAS ZÉRO. NULL = « le réglage par défaut ». * * Un `0` interdirait tout envoi — ce qui est un réglage LÉGITIME et * qu'on doit pouvoir poser volontairement (compte signalé, abus). * Si NULL valait zéro, tous les comptes existants seraient muets du * jour où la colonne apparaît. * * La distinction se paie d'une colonne nullable et se gagne en * lisibilité : le back-office affiche « par défaut (5) » ou une * valeur explicite, jamais un zéro ambigu. * * @var int|null * * @ORM\Column(name="share_quota_per_day", type="integer", nullable=true, options={"comment":"Envois de CV anonyme par 24h (NULL = défaut)"}) */ private $shareQuotaPerDay; public function getShareQuotaPerDay(): ?int { return $this->shareQuotaPerDay; } public function setShareQuotaPerDay(?int $shareQuotaPerDay): self { $this->shareQuotaPerDay = $shareQuotaPerDay === null ? null : max(0, min(500, $shareQuotaPerDay)); return $this; } /** * Le quota EFFECTIF : la valeur du compte, ou le défaut. * * ⚠️ UN SEUL ENDROIT RÉPOND À CETTE QUESTION. * * Écrire `$user->getShareQuotaPerDay() ?? 5` dans le contrôleur * marcherait — jusqu'au deuxième endroit qui le fait, avec un autre * défaut. Et le jour où le défaut change, on en corrige un sur deux. */ public function quotaPartageParJour(int $defaut = 5): int { return $this->shareQuotaPerDay ?? $defaut; } public function isSearchEnabled(): bool { return (bool) $this->searchEnabled; } public function getSearchRequestedAt(): ?\DateTimeInterface { return $this->searchRequestedAt; } public function setSearchRequestedAt(?\DateTimeInterface $v): self { $this->searchRequestedAt = $v; return $this; } public function setSearchEnabled(?bool $searchEnabled): self { $this->searchEnabled = (bool) $searchEnabled; return $this; } /** * Le compte peut-il utiliser le moteur de recherche ? (option A) * * Un seul endroit répond à cette question. La dupliquer dans chaque * endpoint, c'est garantir qu'un jour l'un des deux verrous sera oublié * quelque part — et ce jour-là, la recherche sera ouverte à tous. */ public function peutChercher(): bool { return $this->isPremium() && $this->isSearchEnabled(); } /** * NOTE: This is not a mapped field of entity metadata, just a simple property. * * @Vich\UploadableField(mapping="avatars_files", fileNameProperty="image.name", size="image.size", mimeType="image.mimeType", originalName="image.originalName", dimensions="image.dimensions") * * @var File|null */ private $imageFile; /** * @ORM\Embedded(class="Vich\UploaderBundle\Entity\File") * * @var EmbeddedFile */ private $image; /** * @var \Agencies * * @ORM\ManyToOne(targetEntity="App\Entity\Core\Agencies") * @ORM\JoinColumns({ * @ORM\JoinColumn(name="current_agency_id", referencedColumnName="id", nullable=true) * }) */ protected $currentAgency; /** * @ORM\Column(name="password_forgotten", type="string", length=180, nullable=true) */ private $passwordForgotten; /** * @var datetime * * @ORM\Column(name="password_forgotten_last_date", type="datetime", nullable=true) */ private $passwordForgottenLastDate; /** * @var string * * @ORM\Column(name="partenariat", type="boolean", nullable=true) */ private $partenariat; /** * @ORM\Column(name="description", type="text", nullable=true) */ private $description; /** * @var string * * @ORM\Column(name="notifications_messages", type="boolean", nullable=true) */ private $notificationsMessages; /** * @var string * * @ORM\Column(name="notifications_suivis", type="boolean", nullable=true) */ private $notificationsSuivis; /** * @var string * * @ORM\Column(name="user_commission_unit", type="float", length=11, nullable=true) */ private $userCommissionUnit; /** * @var string * * @ORM\Column(name="user_commission_pourcent", type="float", length=11, nullable=true) */ private $userCommissionPourcent; /** * @var string * * @ORM\Column(name="type_account", type="string", length=255, nullable=true) */ private $typeAccount; /** * @var \Candidates * * @ORM\ManyToOne(targetEntity="App\Entity\Cvs\Candidates") * @ORM\JoinColumns({ * @ORM\JoinColumn(name="candidate_id", referencedColumnName="id", nullable=true) * }) */ protected $candidate; /** * @var string * * @ORM\Column(name="subscription_customer_stripe", type="string", length=255, nullable=true) */ private $subscriptionCustomerStripe; /** * @ORM\Column(type="string", length=255, nullable=true) */ private $verificationToken; /** * @ORM\Column(type="datetime", nullable=true) */ private $tokenExpiration; /** * @ORM\Column(name="language", type="text", nullable=true) */ private $language; /** * @ORM\Column(name="motif_verification", type="text", nullable=true) */ private $motif; public function __construct() { $this->image = new \Vich\UploaderBundle\Entity\File(); } /** * @ORM\PrePersist */ public function setCreatedAtValue(): void { $this->setCreatedAt( new \DateTime("now")); $this->setUpdatedAt( new \DateTime("now")); $this->setPremium(false); } /** * @ORM\PreUpdate */ public function setUpdatedAtValue(): void { $this->setUpdatedAt( new \DateTime("now")); } public function __toString() { return "#".$this->id." ".$this->getName()." ".$this->getLastname().' ('.$this->getEmail().')'; } public function getTitlePartner(): ?string { return "#".$this->id." ".$this->getName()." ".$this->getLastname(); } public function serialize(): string { return serialize([ $this->id, $this->email, $this->password, // autres propriétés que vous souhaitez sérialiser, sauf $imageFile ]); } public function unserialize($serialized): void { list( $this->id, $this->email, $this->password, // autres propriétés que vous souhaitez désérialiser, sauf $imageFile ) = unserialize($serialized); } public function getId(): ?int { return $this->id; } /** * Retourne l'identifiant unique de l'utilisateur pour Symfony 5.3+ * On utilise l'email comme identifiant */ public function getUserIdentifier(): string { return (string) $this->email; } public function getEmail(): ?string { return $this->email; } public function setEmail(string $email): self { $this->email = $email; return $this; } /** * @see UserInterface */ public function getRoles(): array { $roles = $this->roles; // guarantee every user at least has ROLE_USER $roles[] = 'ROLE_USER'; return array_unique($roles); } public function setRoles(array $roles): self { $this->roles = $roles; return $this; } /** * If manually uploading a file (i.e. not using Symfony Form) ensure an instance * of 'UploadedFile' is injected into this setter to trigger the update. If this * bundle's configuration parameter 'inject_on_load' is set to 'true' this setter * must be able to accept an instance of 'File' as the bundle will inject one here * during Doctrine hydration. * * @param File|UploadedFile|null $imageFile */ public function setImageFile(?File $imageFile = null) { $this->imageFile = $imageFile; if (null !== $imageFile) { // It is required that at least one field changes if you are using doctrine // otherwise the event listeners won't be called and the file is lost $this->setUpdatedAt(new \DateTime("now")); } } public function getImageFile(): ?File { return $this->imageFile; } public function getImageBase64(): ?string { if (!$this->image || !$this->image->getName()) { return null; } // Utiliser $_SERVER['DOCUMENT_ROOT'] qui pointe vers /public $filePath = $_SERVER['DOCUMENT_ROOT'] . '/uploads/avatars/' . $this->image->getName(); if (!file_exists($filePath)) { // Debug : retourner le chemin pour voir ce qui cloche return null; // ou "Path: " . $filePath pour debugger } $imageData = file_get_contents($filePath); $mimeType = $this->image->getMimeType() ?? mime_content_type($filePath); return 'data:' . $mimeType . ';base64,' . base64_encode($imageData); } public function setImage(EmbeddedFile $image): void { $this->image = $image; } public function getImage(): ?EmbeddedFile { return $this->image; } /** * @see UserInterface */ public function getPassword(): string { return (string) $this->password; } /** * @see UserInterface */ public function getUsername(): string { return (string) $this->username; } public function setPassword(string $password): self { $this->password = $password; return $this; } /** * @see UserInterface */ public function getSalt() { // not needed when using the "bcrypt" algorithm in security.yaml } /** * @see UserInterface */ public function eraseCredentials() { // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; } public function getCreatedAt(): ?\DateTimeInterface { return $this->createdAt; } public function setCreatedAt(?\DateTimeInterface $createdAt): self { $this->createdAt = $createdAt; return $this; } public function getUpdatedAt(): ?\DateTimeInterface { return $this->updatedAt; } public function setUpdatedAt(?\DateTimeInterface $updatedAt): self { $this->updatedAt = $updatedAt; return $this; } public function getLastLogin(): ?\DateTimeInterface { return $this->lastLogin; } public function setLastLogin(?\DateTimeInterface $lastLogin): self { $this->lastLogin = $lastLogin; return $this; } public function getName(): ?string { return $this->name; } public function setName(?string $name): self { $this->name = $name; return $this; } public function getLastname(): ?string { return $this->lastname; } public function setLastname(?string $lastname): self { $this->lastname = $lastname; return $this; } public function setUsername(?string $username): self { $this->username = $username; return $this; } public function getFirst(): ?bool { return $this->first; } public function setFirst(?bool $first): self { $this->first = $first; return $this; } public function getEnabled(): ?bool { return $this->enabled; } public function setEnabled(?bool $enabled): self { $this->enabled = $enabled; return $this; } public function getPremium(): ?bool { return $this->premium; } public function setPremium(bool $premium): self { $this->premium = $premium; return $this; } public function getPasswordForgotten(): ?string { return $this->passwordForgotten; } public function setPasswordForgotten(?string $passwordForgotten): self { $this->passwordForgotten = $passwordForgotten; return $this; } public function getPasswordForgottenLastDate(): ?\DateTimeInterface { return $this->passwordForgottenLastDate; } public function setPasswordForgottenLastDate(?\DateTimeInterface $passwordForgottenLastDate): self { $this->passwordForgottenLastDate = $passwordForgottenLastDate; return $this; } public function getPartenariat(): ?bool { return $this->partenariat; } public function setPartenariat(?bool $partenariat): self { $this->partenariat = $partenariat; return $this; } public function getDescription(): ?string { return $this->description; } public function setDescription(?string $description): self { $this->description = $description; return $this; } public function getNotificationsMessages(): ?bool { return $this->notificationsMessages; } public function setNotificationsMessages(?bool $notificationsMessages): self { $this->notificationsMessages = $notificationsMessages; return $this; } public function getNotificationsSuivis(): ?bool { return $this->notificationsSuivis; } public function setNotificationsSuivis(?bool $notificationsSuivis): self { $this->notificationsSuivis = $notificationsSuivis; return $this; } public function getUserCommissionUnit(): ?float { return $this->userCommissionUnit; } public function setUserCommissionUnit(?float $userCommissionUnit): self { $this->userCommissionUnit = $userCommissionUnit; return $this; } public function getUserCommissionPourcent(): ?float { return $this->userCommissionPourcent; } public function setUserCommissionPourcent(?float $userCommissionPourcent): self { $this->userCommissionPourcent = $userCommissionPourcent; return $this; } public function getCurrentAgency(): ?Agencies { return $this->currentAgency; } public function setCurrentAgency(?Agencies $currentAgency): self { $this->currentAgency = $currentAgency; return $this; } public function isFirst(): ?bool { return $this->first; } public function isEnabled(): ?bool { return $this->enabled; } public function isPremium(): ?bool { return $this->premium; } public function isPartenariat(): ?bool { return $this->partenariat; } public function isNotificationsMessages(): ?bool { return $this->notificationsMessages; } public function isNotificationsSuivis(): ?bool { return $this->notificationsSuivis; } public function getTypeAccount(): ?string { return $this->typeAccount; } public function setTypeAccount(?string $description): self { $this->typeAccount = $description; return $this; } public function getCandidate(): ?Candidates { return $this->candidate; } public function setCandidate(?Candidates $candidate): static { $this->candidate = $candidate; return $this; } public function getSubscriptionCustomerStripe(): ?string { return $this->subscriptionCustomerStripe; } public function setSubscriptionCustomerStripe(?string $subscriptionCustomerStripe): static { $this->subscriptionCustomerStripe = $subscriptionCustomerStripe; return $this; } public function getVerificationToken(): ?string { return $this->verificationToken; } public function setVerificationToken(?string $verificationToken): self { $this->verificationToken = $verificationToken; return $this; } public function getTokenExpiration(): ?\DateTimeInterface { return $this->tokenExpiration; } public function setTokenExpiration(?\DateTimeInterface $tokenExpiration): self { $this->tokenExpiration = $tokenExpiration; return $this; } public function getLanguage(): ?string { return $this->language; } public function setLanguage(?string $description): self { $this->language = $description; return $this; } public function isVerification(): ?bool { return $this->verification; } public function setVerification(?bool $verification): self { $this->verification = $verification; return $this; } public function getMotif(): ?string { return $this->motif; } public function setMotif(?string $motif): self { $this->motif = $motif; return $this; } public function getIaOffreCount(): int { return (int) $this->iaOffreCount; } public function setIaOffreCount(int $n): self { $this->iaOffreCount = $n; return $this; } public function getIaOffreDay(): ?\DateTimeInterface { return $this->iaOffreDay; } public function setIaOffreDay(?\DateTimeInterface $jour): self { $this->iaOffreDay = $jour; return $this; } /** * Combien de rédactions ce compte a consommées AUJOURD'HUI. * * ⚠️ UN COMPTEUR D'HIER VAUT ZÉRO, IL NE SE PURGE PAS. * * La remise à zéro est implicite : si la date stockée n'est pas * celle du jour, le compteur ne concerne pas aujourd'hui. Pas de * travail planifié, donc pas de travail planifié en panne. */ public function iaOffreUtiliseAujourdhui(): int { if (!$this->iaOffreDay) { return 0; } return $this->iaOffreDay->format('Y-m-d') === (new \DateTime())->format('Y-m-d') ? (int) $this->iaOffreCount : 0; } public function getIaOffreQuota(): ?int { return $this->iaOffreQuota; } public function setIaOffreQuota(?int $n): self { $this->iaOffreQuota = $n; return $this; } /** * Le plafond applicable à ce compte. * * ⚠️ LE RÉGLAGE INDIVIDUEL L'EMPORTE, Y COMPRIS À LA BAISSE. * * Un abonné à qui l'on a fixé 5 aura 5, pas 100. C'est voulu : le * champ sert autant à ouvrir qu'à restreindre — un compte qui abuse * se borne sans qu'on ait à lui retirer son abonnement. * * @param int $defaut le plafond selon l'abonnement, décidé par l'appelant */ public function iaOffrePlafond(int $defaut): int { return $this->iaOffreQuota === null ? $defaut : max(0, (int) $this->iaOffreQuota); } /** Enregistre une rédaction de plus pour aujourd'hui. */ public function iaOffreIncremente(): self { $this->iaOffreCount = $this->iaOffreUtiliseAujourdhui() + 1; $this->iaOffreDay = new \DateTime(); return $this; }}