vcard4reseller/backend/src/Entity/Employee.php
Thomas Peterson 2dc40c6ea5 Rechte: Mitarbeiter & Benutzer vereint, ROLE_CONTACT als Basis
- Jeder Mitarbeiter hat (leere) Login-/Passwortfelder; Standardrolle ROLE_CONTACT
  (reines Profil). Hochstufen über die Rechtegruppe.
- Ebenen-Ladder: contact(0) < employee(1) < company_admin(2) < reseller_admin(3)
  < platform_admin(4); role_hierarchy + RoleService entsprechend.
- PATCH /api/employees/{id}/access: Rechtegruppe setzen (+ optional Passwort/Login);
  DELETE .../login → zurück auf Kontakt.
- Sicherheit: Passwort/userIdentifier per #[Ignore] aus der API-Serialisierung.
- Frontend: separate Benutzer-Ansicht entfernt; Mitarbeiter-Liste mit
  Rechtegruppe-Spalte, Rollen/Login + 'Arbeiten als' inline im Bearbeiten-Dialog.

Verifiziert: kein Passwort-Leak, roles/login im Payload, Hochstufen Kontakt→
Mitarbeiter + Login, Eskalation→403, Login entziehen→Kontakt; UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:44:57 +02:00

445 lines
10 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\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\EmployeeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Uid\Uuid;
/**
* Mitarbeiterprofil — Single Source of Truth für alle Ausgabekanäle UND
* Login-Identität (Auth). Login/Passwort sind optional: ein Mitarbeiter kann
* ohne eigenes Login existieren (Visitenkarte/NFC) und bei Bedarf eine
* Rechtegruppe + Login erhalten.
*/
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_employee_company_slug', fields: ['company', 'slug'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_shortcode', fields: ['shortCode'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_login_email', fields: ['loginEmail'])]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthenticatedUserInterface
{
public const ROLE_PLATFORM_ADMIN = 'ROLE_PLATFORM_ADMIN';
public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN';
public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE';
/** Basis-Rolle: reines Profil/Kontakt (Visitenkarte), kein Login nötig. */
public const ROLE_CONTACT = 'ROLE_CONTACT';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 100)]
private string $firstName;
#[ORM\Column(length: 100)]
private string $lastName;
/** Öffentlicher Slug (eindeutig innerhalb der Firma). */
#[ORM\Column(length: 120)]
private string $slug;
/**
* Stabiler Kurz-Code für NFC-Tags / gedruckte QR-Codes. Verweist per
* Redirect (/t/{code}) immer auf das aktuelle Profil bleibt also gleich,
* auch wenn sich der Slug ändert. Generiert beim Anlegen, nicht editierbar.
*/
#[ORM\Column(length: 16, nullable: true)]
private ?string $shortCode = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $position = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $department = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $mobile = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $photoPath = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $bio = null;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Pro-Mitarbeiter-Freigabe für Self-Service (zusätzlich zu Company::selfEditEnabled). */
#[ORM\Column]
private bool $selfEditAllowed = false;
/** @var string[] Felder, die der Mitarbeiter selbst ändern darf (leer = alle erlaubten). */
#[ORM\Column(type: 'json')]
private array $editableFields = [];
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'employees')]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
#[ORM\ManyToOne(targetEntity: Location::class)]
private ?Location $location = null;
// --- Login/Auth (optional) ---
/** Eindeutige Login-E-Mail; null = kein Login. */
#[ORM\Column(length: 180, nullable: true)]
private ?string $loginEmail = null;
#[ORM\Column(nullable: true)]
private ?string $password = null;
/** @var string[] Rechtegruppe(n); Standard = ROLE_CONTACT (reines Profil). */
#[ORM\Column(type: 'json')]
private array $roles = [self::ROLE_CONTACT];
/** @var Collection<int, ContactLink> */
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $contactLinks;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->shortCode = bin2hex(random_bytes(4));
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
$this->contactLinks = new ArrayCollection();
}
public function getShortCode(): ?string
{
return $this->shortCode;
}
public function ensureShortCode(): void
{
if (null === $this->shortCode) {
$this->shortCode = bin2hex(random_bytes(4));
}
}
public function getId(): Uuid
{
return $this->id;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): string
{
return $this->lastName;
}
public function setLastName(string $lastName): self
{
$this->lastName = $lastName;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): self
{
$this->title = $title;
return $this;
}
public function getPosition(): ?string
{
return $this->position;
}
public function setPosition(?string $position): self
{
$this->position = $position;
return $this;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function setDepartment(?string $department): self
{
$this->department = $department;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getMobile(): ?string
{
return $this->mobile;
}
public function setMobile(?string $mobile): self
{
$this->mobile = $mobile;
return $this;
}
public function getPhotoPath(): ?string
{
return $this->photoPath;
}
public function setPhotoPath(?string $photoPath): self
{
$this->photoPath = $photoPath;
return $this;
}
public function getBio(): ?string
{
return $this->bio;
}
public function setBio(?string $bio): self
{
$this->bio = $bio;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function isSelfEditAllowed(): bool
{
return $this->selfEditAllowed;
}
public function setSelfEditAllowed(bool $selfEditAllowed): self
{
$this->selfEditAllowed = $selfEditAllowed;
return $this;
}
/** @return string[] */
public function getEditableFields(): array
{
return $this->editableFields;
}
/** @param string[] $editableFields */
public function setEditableFields(array $editableFields): self
{
$this->editableFields = $editableFields;
return $this;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getLocation(): ?Location
{
return $this->location;
}
public function setLocation(?Location $location): self
{
$this->location = $location;
return $this;
}
// --- Login/Auth ---
public function getLoginEmail(): ?string
{
return $this->loginEmail;
}
public function setLoginEmail(?string $loginEmail): self
{
$this->loginEmail = $loginEmail;
return $this;
}
public function hasLogin(): bool
{
return null !== $this->loginEmail && null !== $this->password;
}
#[Ignore]
public function getUserIdentifier(): string
{
return (string) $this->loginEmail;
}
/** @return string[] */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
/** @param string[] $roles */
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
#[Ignore]
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(?string $password): self
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
}
/** @return Collection<int, ContactLink> */
public function getContactLinks(): Collection
{
return $this->contactLinks;
}
public function addContactLink(ContactLink $link): self
{
if (!$this->contactLinks->contains($link)) {
$this->contactLinks->add($link);
$link->setEmployee($this);
}
return $this;
}
public function removeContactLink(ContactLink $link): self
{
$this->contactLinks->removeElement($link);
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}