usersCache->getItem(self::KEY_PREFIX . $user->id); $item->set($this->serialize($user)); $this->usersCache->save($item); // Save email index for lookup (scoped to tenant) $emailKey = $this->emailIndexKey($user->email, $user->tenantId); $emailItem = $this->usersCache->getItem($emailKey); $emailItem->set((string) $user->id); $this->usersCache->save($emailItem); // Save tenant index for listing users $tenantKey = self::TENANT_INDEX_PREFIX . $user->tenantId; $tenantItem = $this->usersCache->getItem($tenantKey); /** @var string[] $userIds */ $userIds = $tenantItem->isHit() ? $tenantItem->get() : []; $userId = (string) $user->id; if (!in_array($userId, $userIds, true)) { $userIds[] = $userId; } $tenantItem->set($userIds); $this->usersCache->save($tenantItem); } #[Override] public function findById(UserId $id): ?User { $item = $this->usersCache->getItem(self::KEY_PREFIX . $id); if (!$item->isHit()) { return null; } /** @var array{id: string, email: string, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */ $data = $item->get(); return $this->deserialize($data); } public function findByEmail(Email $email, TenantId $tenantId): ?User { $emailKey = $this->emailIndexKey($email, $tenantId); $emailItem = $this->usersCache->getItem($emailKey); if (!$emailItem->isHit()) { return null; } /** @var string $userId */ $userId = $emailItem->get(); return $this->findById(UserId::fromString($userId)); } public function get(UserId $id): User { $user = $this->findById($id); if ($user === null) { throw UserNotFoundException::withId($id); } return $user; } public function findAllByTenant(TenantId $tenantId): array { $tenantKey = self::TENANT_INDEX_PREFIX . $tenantId; $tenantItem = $this->usersCache->getItem($tenantKey); if (!$tenantItem->isHit()) { return []; } /** @var string[] $userIds */ $userIds = $tenantItem->get(); $users = []; foreach ($userIds as $userId) { $user = $this->findById(UserId::fromString($userId)); if ($user !== null) { $users[] = $user; } } return $users; } #[Override] public function findStudentsByTenant(TenantId $tenantId): array { return array_values(array_filter( $this->findAllByTenant($tenantId), static fn (User $user): bool => $user->aLeRole(Role::ELEVE), )); } /** * @return array */ private function serialize(User $user): array { $consentement = $user->consentementParental; return [ 'id' => (string) $user->id, 'email' => (string) $user->email, 'roles' => array_map(static fn (Role $r) => $r->value, $user->roles), 'tenant_id' => (string) $user->tenantId, 'school_name' => $user->schoolName, 'statut' => $user->statut->value, 'hashed_password' => $user->hashedPassword, 'date_naissance' => $user->dateNaissance?->format('Y-m-d'), 'created_at' => $user->createdAt->format('c'), 'activated_at' => $user->activatedAt?->format('c'), 'first_name' => $user->firstName, 'last_name' => $user->lastName, 'invited_at' => $user->invitedAt?->format('c'), 'blocked_at' => $user->blockedAt?->format('c'), 'blocked_reason' => $user->blockedReason, 'image_rights_status' => $user->imageRightsStatus->value, 'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'), 'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null, 'consentement_parental' => $consentement !== null ? [ 'parent_id' => $consentement->parentId, 'eleve_id' => $consentement->eleveId, 'date_consentement' => $consentement->dateConsentement->format('c'), 'ip_address' => $consentement->ipAddress, ] : null, ]; } /** * @param array{ * id: string, * email: string, * roles?: string[], * role?: string, * tenant_id: string, * school_name: string, * statut: string, * hashed_password: string|null, * date_naissance: string|null, * created_at: string, * activated_at: string|null, * first_name?: string, * last_name?: string, * invited_at?: string|null, * blocked_at?: string|null, * blocked_reason?: string|null, * image_rights_status?: string, * image_rights_updated_at?: string|null, * image_rights_updated_by?: string|null, * consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null * } $data */ private function deserialize(array $data): User { $consentement = null; if ($data['consentement_parental'] !== null) { $consentementData = $data['consentement_parental']; $consentement = ConsentementParental::accorder( parentId: $consentementData['parent_id'], eleveId: $consentementData['eleve_id'], at: new DateTimeImmutable($consentementData['date_consentement']), ipAddress: $consentementData['ip_address'], ); } $invitedAt = ($data['invited_at'] ?? null) !== null ? new DateTimeImmutable($data['invited_at']) : null; $blockedAt = ($data['blocked_at'] ?? null) !== null ? new DateTimeImmutable($data['blocked_at']) : null; // Support both legacy single role and new multi-role format $roleStrings = $data['roles'] ?? (isset($data['role']) ? [$data['role']] : []); if ($roleStrings === []) { throw new RuntimeException(sprintf('User %s has no roles in cache data.', $data['id'])); } $roles = array_map(static fn (string $r) => Role::from($r), $roleStrings); return User::reconstitute( id: UserId::fromString($data['id']), email: new Email($data['email']), roles: $roles, tenantId: TenantId::fromString($data['tenant_id']), schoolName: $data['school_name'], statut: StatutCompte::from($data['statut']), dateNaissance: $data['date_naissance'] !== null ? new DateTimeImmutable($data['date_naissance']) : null, createdAt: new DateTimeImmutable($data['created_at']), hashedPassword: $data['hashed_password'], activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null, consentementParental: $consentement, firstName: $data['first_name'] ?? '', lastName: $data['last_name'] ?? '', invitedAt: $invitedAt, blockedAt: $blockedAt, blockedReason: $data['blocked_reason'] ?? null, imageRightsStatus: isset($data['image_rights_status']) ? ImageRightsStatus::from($data['image_rights_status']) : ImageRightsStatus::NOT_SPECIFIED, imageRightsUpdatedAt: ($data['image_rights_updated_at'] ?? null) !== null ? new DateTimeImmutable($data['image_rights_updated_at']) : null, imageRightsUpdatedBy: ($data['image_rights_updated_by'] ?? null) !== null ? UserId::fromString($data['image_rights_updated_by']) : null, ); } private function normalizeEmail(Email $email): string { return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email)); } /** * Creates a cache key for email lookup scoped to a tenant. */ private function emailIndexKey(Email $email, TenantId $tenantId): string { return self::EMAIL_INDEX_PREFIX . $tenantId . ':' . $this->normalizeEmail($email); } }