]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Entity/Adherent.php
f9bdbe971a1a3ef5086252308d2069e383600e31
[galette.git] / galette / lib / Galette / Entity / Adherent.php
1 <?php
2
3 /**
4 * Copyright © 2003-2024 The Galette Team
5 *
6 * This file is part of Galette (https://galette.eu).
7 *
8 * Galette is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * Galette is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License
19 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
20 */
21
22 namespace Galette\Entity;
23
24 use ArrayObject;
25 use DateInterval;
26 use DateTime;
27 use Galette\Core\I18n;
28 use Galette\Events\GaletteEvent;
29 use Galette\Features\HasEvent;
30 use Galette\Features\Socials;
31 use Throwable;
32 use Analog\Analog;
33 use Laminas\Db\Sql\Expression;
34 use Galette\Core\Db;
35 use Galette\Core\Picture;
36 use Galette\Core\GaletteMail;
37 use Galette\Core\Password;
38 use Galette\Core\Preferences;
39 use Galette\Core\History;
40 use Galette\Repository\Groups;
41 use Galette\Core\Login;
42 use Galette\Repository\Members;
43 use Galette\Features\Dynamics;
44
45 /**
46 * Member class for galette
47 *
48 * @author Johan Cwiklinski <johan@x-tnd.be>
49 *
50 * @property ?integer $id
51 * @property integer|Title|null $title Either a title id or an instance of Title
52 * @property string $stitle Title label
53 * @property string $company_name
54 * @property string $name
55 * @property ?string $surname
56 * @property string $nickname
57 * @property ?string $birthdate Localized birthdate
58 * @property ?string $rbirthdate Raw birthdate
59 * @property string $birth_place
60 * @property integer $gender
61 * @property string $sgender Gender label
62 * @property string $job
63 * @property string $language
64 * @property integer $status
65 * @property string $sstatus Status label
66 * @property ?string $address
67 * @property ?string $zipcode
68 * @property ?string $town
69 * @property ?string $country
70 * @property string $phone
71 * @property string $gsm
72 * @property ?string $email
73 * @property string $gnupgid
74 * @property string $fingerprint
75 * @property ?string $login
76 * @property string $creation_date Localized creation date
77 * @property string $modification_date Localized modification date
78 * @property string $due_date Localized due date
79 * @property string $others_infos
80 * @property string $others_infos_admin
81 * @property Picture $picture
82 * @property array $groups
83 * @property array $managed_groups
84 * @property integer|Adherent|null $parent Parent id if parent dep is not loaded, Adherent instance otherwise
85 * @property array $children
86 * @property boolean $admin better to rely on isAdmin()
87 * @property boolean $staff better to rely on isStaff()
88 * @property boolean $due_free better to rely on isDueFree()
89 * @property boolean $appears_in_list better to rely on appearsInMembersList()
90 * @property boolean $active better to rely on isActive()
91 * @property boolean $duplicate better to rely on isDuplicate()
92 * @property string $sadmin yes/no
93 * @property string $sstaff yes/no
94 * @property string $sdue_free yes/no
95 * @property string $sappears_in_list yes/no
96 * @property string $sactive yes/no
97 * @property string $sfullname
98 * @property string $sname
99 * @property string $saddress
100 * @property string $contribstatus State of member contributions
101 * @property integer $days_remaining
102 * @property-read integer $parent_id
103 * @property Social $social Social networks/Contact
104 * @property string $number Member number
105 * @property-read bool $self_adh
106 * @property ?string $region
107 */
108 class Adherent
109 {
110 use Dynamics;
111 use Socials;
112 use HasEvent;
113
114 public const TABLE = 'adherents';
115 public const PK = 'id_adh';
116
117 public const NC = 0;
118 public const MAN = 1;
119 public const WOMAN = 2;
120
121 public const AFTER_ADD_DEFAULT = 0;
122 public const AFTER_ADD_TRANS = 1;
123 public const AFTER_ADD_NEW = 2;
124 public const AFTER_ADD_SHOW = 3;
125 public const AFTER_ADD_LIST = 4;
126 public const AFTER_ADD_HOME = 5;
127
128 private ?int $id;
129 //Identity
130 private Title|string|null $title = null;
131 private ?string $company_name;
132 private ?string $name;
133 private ?string $surname;
134 private ?string $nickname;
135 private ?string $birthdate;
136 private ?string $birth_place;
137 private int $gender;
138 private string $job;
139 private string $language;
140 private bool $active;
141 private int $status;
142 //Contact information
143 private ?string $address = null;
144 private ?string $zipcode = null;
145 private ?string $town = null;
146 private ?string $country = null;
147 private ?string $phone;
148 private ?string $gsm;
149 private ?string $email;
150 private ?string $gnupgid;
151 private ?string $fingerprint;
152 //Galette relative information
153 private bool $appears_in_list;
154 private bool $admin;
155 private bool $staff = false;
156 private bool $due_free;
157 private ?string $login;
158 private ?string $password;
159 private string $creation_date;
160 private string $modification_date;
161 private ?string $due_date;
162 private string $others_infos;
163 private ?string $others_infos_admin;
164 private ?Picture $picture = null;
165 private int $oldness;
166 private ?int $days_remaining = null;
167 /** @var array<int, Group> */
168 private array $groups = [];
169 /** @var array<int, Group> */
170 private array $managed_groups = [];
171 private int|Adherent|null $parent;
172 /** @var array<int, Adherent>|null */
173 private ?array $children = [];
174 private bool $duplicate = false;
175 /** @var array<int,Social> */
176 private array $socials;
177 private ?string $number = null;
178 private ?string $region = null;
179
180 private string $row_classes;
181
182 private bool $self_adh = false;
183
184 private Db $zdb;
185 private Preferences $preferences;
186 /** @var array<string, mixed> */
187 private array $fields;
188 private History $history;
189 private int $due_status = Contribution::STATUS_UNKNOWN;
190
191 /** @var array<string> */
192 private array $parent_fields = [
193 'adresse_adh',
194 'cp_adh',
195 'ville_adh',
196 'email_adh'
197 ];
198
199 /** @var array<string> */
200 private array $errors = [];
201
202 private bool $sendmail = false;
203
204 /**
205 * Default constructor
206 *
207 * @param Db $zdb Database instance
208 * @param ArrayObject<string, int|string>|string|int|null $args Either a ResultSet row, its id or its
209 * login or its email for to load s specific
210 * member, or null to just instantiate object
211 * @param false|array<string,bool>|null $deps Dependencies configuration, see Adherent::$_deps
212 */
213 public function __construct(Db $zdb, ArrayObject|int|string $args = null, array|false $deps = null)
214 {
215 /** @var I18n $i18n */
216 global $i18n;
217
218 $this->zdb = $zdb;
219
220 if ($deps !== null) {
221 if (is_array($deps)) {
222 $this->setDeps($deps);
223 } elseif ($deps === false) {
224 //no dependencies
225 $this->disableAllDeps();
226 } else {
227 Analog::log(
228 '$deps should be an array, ' . gettype($deps) . ' given!',
229 Analog::WARNING
230 );
231 }
232 }
233
234 $this
235 ->withAddEvent()
236 ->withEditEvent()
237 ->withoutDeleteEvent()
238 ->activateEvents();
239
240 if ($args == null || is_int($args)) {
241 if (is_int($args) && $args > 0) {
242 $this->load($args);
243 } else {
244 $this->active = true;
245 $this->language = $i18n->getID();
246 $this->creation_date = date("Y-m-d");
247 $this->status = $this->getDefaultStatus();
248 $this->title = null;
249 $this->gender = self::NC;
250 $gp = new Password($this->zdb);
251 $this->password = $gp->makeRandomPassword();
252 $this->picture = new Picture();
253 $this->admin = false;
254 $this->staff = false;
255 $this->due_free = false;
256 $this->appears_in_list = false;
257 $this->parent = null;
258
259 if ($this->deps['dynamics'] === true) {
260 $this->loadDynamicFields();
261 }
262 }
263 } elseif ($args instanceof ArrayObject) {
264 $this->loadFromRS($args);
265 } elseif (is_string($args)) {
266 $this->loadFromLoginOrMail($args);
267 }
268 }
269
270 /**
271 * Loads a member from its id
272 *
273 * @param int $id the identifier for the member to load
274 *
275 * @return bool true if query succeed, false otherwise
276 */
277 public function load(int $id): bool
278 {
279 try {
280 $select = $this->zdb->select(self::TABLE, 'a');
281
282 $select->join(
283 array('b' => PREFIX_DB . Status::TABLE),
284 'a.' . Status::PK . '=b.' . Status::PK,
285 array('priorite_statut')
286 )->where(array(self::PK => $id));
287
288 $results = $this->zdb->execute($select);
289
290 if ($results->count() === 0) {
291 return false;
292 }
293
294 /** @var ArrayObject<string, int|string> $result */
295 $result = $results->current();
296 $this->loadFromRS($result);
297 return true;
298 } catch (Throwable $e) {
299 Analog::log(
300 'Cannot load member form id `' . $id . '` | ' . $e->getMessage(),
301 Analog::WARNING
302 );
303 throw $e;
304 }
305 }
306
307 /**
308 * Loads a member from its login
309 *
310 * @param string $login login for the member to load
311 *
312 * @return boolean
313 */
314 public function loadFromLoginOrMail(string $login): bool
315 {
316 try {
317 $select = $this->zdb->select(self::TABLE);
318 if (GaletteMail::isValidEmail($login)) {
319 //we got a valid email address, use it
320 $select->where(array('email_adh' => $login));
321 } else {
322 ///we did not get an email address, consider using login
323 $select->where(array('login_adh' => $login));
324 }
325
326 $results = $this->zdb->execute($select);
327 if ($results->count() > 0) {
328 /** @var ArrayObject<string, int|string> $result */
329 $result = $results->current();
330 $this->loadFromRS($result);
331 }
332 return true;
333 } catch (Throwable $e) {
334 Analog::log(
335 'Cannot load member form login `' . $login . '` | ' .
336 $e->getMessage(),
337 Analog::WARNING
338 );
339 throw $e;
340 }
341 }
342
343 /**
344 * Populate object from a resultset row
345 *
346 * @param ArrayObject<string, int|string> $r the resultset row
347 *
348 * @return void
349 */
350 private function loadFromRS(ArrayObject $r): void
351 {
352 $this->self_adh = false;
353 $this->id = $r->id_adh;
354 //Identity
355 if ($r->titre_adh !== null) {
356 $this->title = new Title((int)$r->titre_adh);
357 }
358 $this->company_name = $r->societe_adh;
359 $this->name = $r->nom_adh;
360 $this->surname = $r->prenom_adh;
361 $this->nickname = $r->pseudo_adh;
362 if ($r->ddn_adh != '1901-01-01') {
363 $this->birthdate = $r->ddn_adh;
364 }
365 $this->birth_place = $r->lieu_naissance;
366 $this->gender = (int)$r->sexe_adh;
367 $this->job = $r->prof_adh;
368 $this->language = $r->pref_lang;
369 $this->active = $r->activite_adh == 1;
370 $this->status = (int)$r->id_statut;
371 //Contact information
372 $this->address = $r->adresse_adh;
373 $this->zipcode = $r->cp_adh;
374 $this->town = $r->ville_adh;
375 $this->region = $r->region_adh;
376 $this->country = $r->pays_adh;
377 $this->phone = $r->tel_adh;
378 $this->gsm = $r->gsm_adh;
379 $this->email = $r->email_adh;
380 $this->gnupgid = $r->gpgid;
381 $this->fingerprint = $r->fingerprint;
382 //Galette relative information
383 $this->appears_in_list = $r->bool_display_info == 1;
384 $this->admin = $r->bool_admin_adh == 1;
385 if (
386 isset($r->priorite_statut)
387 && $r->priorite_statut < Members::NON_STAFF_MEMBERS
388 ) {
389 $this->staff = true;
390 }
391 $this->due_free = $r->bool_exempt_adh == 1;
392 $this->login = $r->login_adh;
393 $this->password = $r->mdp_adh;
394 $this->creation_date = $r->date_crea_adh;
395 if ($r->date_modif_adh != '1901-01-01') {
396 $this->modification_date = $r->date_modif_adh;
397 } else {
398 $this->modification_date = $this->creation_date;
399 }
400 $this->due_date = $r->date_echeance;
401 $this->others_infos = $r->info_public_adh;
402 $this->others_infos_admin = $r->info_adh;
403 $this->number = $r->num_adh;
404
405 if ($r->parent_id !== null) {
406 $this->parent = (int)$r->parent_id;
407 if ($this->deps['parent'] === true) {
408 $this->loadParent();
409 }
410 }
411
412 if ($this->deps['children'] === true) {
413 $this->loadChildren();
414 }
415
416 if ($this->deps['picture'] === true) {
417 $this->picture = new Picture($this->id);
418 }
419
420 if ($this->deps['groups'] === true) {
421 $this->loadGroups();
422 }
423
424 if ($this->deps['dues'] === true) {
425 $this->checkDues();
426 }
427
428 if ($this->deps['dynamics'] === true) {
429 $this->loadDynamicFields();
430 }
431
432 if ($this->deps['socials'] === true) {
433 $this->loadSocials();
434 }
435 }
436
437 /**
438 * Load member parent
439 *
440 * @return void
441 */
442 private function loadParent(): void
443 {
444 if (isset($this->parent) && !$this->parent instanceof Adherent) {
445 $deps = array_fill_keys(array_keys($this->deps), false);
446 $this->parent = new Adherent($this->zdb, (int)$this->parent, $deps);
447 }
448 }
449
450 /**
451 * Load member children
452 *
453 * @return void
454 */
455 private function loadChildren(): void
456 {
457 $this->children = array();
458 try {
459 $id = self::PK;
460 $select = $this->zdb->select(self::TABLE);
461 $select->columns(
462 array($id)
463 )->where(['parent_id' => $this->id]);
464
465 $results = $this->zdb->execute($select);
466
467 if ($results->count() > 0) {
468 foreach ($results as $row) {
469 $deps = $this->deps;
470 $deps['children'] = false;
471 $deps['parent'] = false;
472 $this->children[] = new Adherent($this->zdb, (int)$row->$id, $deps);
473 }
474 }
475 } catch (Throwable $e) {
476 Analog::log(
477 'Cannot load children for member #' . $this->id . ' | ' .
478 $e->getMessage(),
479 Analog::WARNING
480 );
481 throw $e;
482 }
483 }
484
485 /**
486 * Load member groups
487 *
488 * @return void
489 */
490 public function loadGroups(): void
491 {
492 $this->groups = Groups::loadGroups($this->id);
493 $this->managed_groups = Groups::loadManagedGroups($this->id);
494 }
495
496 /**
497 * Load member social network/contact information
498 *
499 * @return void
500 */
501 public function loadSocials(): void
502 {
503 $this->socials = Social::getListForMember($this->id);
504 }
505
506 /**
507 * Retrieve status from preferences
508 *
509 * @return integer
510 *
511 */
512 private function getDefaultStatus(): int
513 {
514 global $preferences;
515 if ($preferences->pref_statut != '') {
516 return $preferences->pref_statut;
517 } else {
518 Analog::log(
519 'Unable to get pref_statut; is it defined in preferences?',
520 Analog::ERROR
521 );
522 return Status::DEFAULT_STATUS;
523 }
524 }
525
526 /**
527 * Check for dues status
528 *
529 * @return void
530 */
531 private function checkDues(): void
532 {
533 //how many days since our beloved member has been created
534 $now = new DateTime();
535 $this->oldness = $now->diff(
536 new DateTime($this->creation_date)
537 )->days;
538
539 $this->row_classes = '';
540 if ($this->isDueFree()) {
541 //no fee required, we don't care about dates
542 $this->row_classes .= ' cotis-exempt';
543 $this->due_status = Contribution::STATUS_DUEFREE;
544 } else {
545 //ok, fee is required. Let's check the dates
546 if ($this->due_date == '') {
547 $this->row_classes .= ' cotis-never';
548 $this->due_status = Contribution::STATUS_NEVER;
549 } else {
550 // To count the days remaining, the next begin date is required.
551 $due_date = new DateTime($this->due_date);
552 $next_begin_date = clone $due_date;
553 $next_begin_date->add(new DateInterval('P1D'));
554 $date_diff = $now->diff($next_begin_date);
555 $this->days_remaining = $date_diff->days;
556 // Active
557 if ($date_diff->invert == 0 && $date_diff->days >= 0) {
558 $this->days_remaining = $date_diff->days;
559 if ($this->days_remaining <= 30) {
560 if ($date_diff->days == 0) {
561 $this->row_classes .= ' cotis-lastday';
562 }
563 $this->row_classes .= ' cotis-soon';
564 $this->due_status = Contribution::STATUS_IMPENDING;
565 } else {
566 $this->row_classes .= ' cotis-ok';
567 $this->due_status = Contribution::STATUS_UPTODATE;
568 }
569 // Expired
570 } elseif ($date_diff->invert == 1 && $date_diff->days >= 0) {
571 $this->days_remaining = $date_diff->days;
572 //check if member is still active
573 if ($this->isActive()) {
574 $this->row_classes .= ' cotis-late';
575 $this->due_status = Contribution::STATUS_LATE;
576 } else {
577 $this->row_classes .= ' cotis-old';
578 $this->due_status = Contribution::STATUS_OLD;
579 }
580 }
581 }
582 }
583
584 if (!$this->isActive()) {
585 //anyway, if a member is no longer active, its due status is old.
586 $this->due_status = Contribution::STATUS_OLD;
587 }
588
589 if ($this->due_status === Contribution::STATUS_UNKNOWN) {
590 throw new \RuntimeException(
591 'Unable to determine due status for member #' . $this->id
592 );
593 }
594 }
595
596 /**
597 * Is member admin?
598 *
599 * @return bool
600 */
601 public function isAdmin(): bool
602 {
603 return $this->admin;
604 }
605
606 /**
607 * Is user member of staff?
608 *
609 * @return bool
610 */
611 public function isStaff(): bool
612 {
613 return $this->staff;
614 }
615
616 /**
617 * Is member freed of dues?
618 *
619 * @return bool
620 */
621 public function isDueFree(): bool
622 {
623 return $this->due_free;
624 }
625
626 /**
627 * Is member in specified group?
628 *
629 * @param string $group_name Group name
630 *
631 * @return boolean
632 */
633 public function isGroupMember(string $group_name): bool
634 {
635 if (!$this->isDepEnabled('groups')) {
636 $this->loadGroups();
637 }
638
639 foreach ($this->groups as $g) {
640 if ($g->getName() == $group_name) {
641 return true;
642 }
643 }
644 return false;
645 }
646
647 /**
648 * Is member manager of specified group?
649 *
650 * @param ?string $group_name Group name
651 *
652 * @return boolean
653 */
654 public function isGroupManager(?string $group_name): bool
655 {
656 if (!$this->isDepEnabled('groups')) {
657 $this->loadGroups();
658 }
659
660 if ($group_name !== null) {
661 foreach ($this->managed_groups as $mg) {
662 if ($mg->getName() == $group_name) {
663 return true;
664 }
665 }
666 return false;
667 } else {
668 if ($this->isAdmin() || $this->isStaff()) {
669 return true;
670 } else {
671 return count($this->managed_groups) > 0;
672 }
673 }
674 }
675
676 /**
677 * Does current member represents a company?
678 *
679 * @return boolean
680 */
681 public function isCompany(): bool
682 {
683 return trim($this->company_name ?? '') != '';
684 }
685
686 /**
687 * Is current member a man?
688 *
689 * @return boolean
690 */
691 public function isMan(): bool
692 {
693 return $this->gender === self::MAN;
694 }
695
696 /**
697 * Is current member a woman?
698 *
699 * @return boolean
700 */
701 public function isWoman(): bool
702 {
703 return $this->gender === self::WOMAN;
704 }
705
706
707 /**
708 * Can member appears in public members list?
709 *
710 * @return bool
711 */
712 public function appearsInMembersList(): bool
713 {
714 return $this->appears_in_list;
715 }
716
717 /**
718 * Is member active?
719 *
720 * @return bool
721 */
722 public function isActive(): bool
723 {
724 return $this->active;
725 }
726
727 /**
728 * Does member have uploaded a picture?
729 *
730 * @return bool
731 */
732 public function hasPicture(): bool
733 {
734 return $this->picture->hasPicture();
735 }
736
737 /**
738 * Does member have a parent?
739 *
740 * @return bool
741 */
742 public function hasParent(): bool
743 {
744 return !empty($this->parent);
745 }
746
747 /**
748 * Does member have children?
749 *
750 * @return bool
751 */
752 public function hasChildren(): bool
753 {
754 if (!isset($this->children) || $this->children === null) {
755 if ($this->id) {
756 Analog::log(
757 'Children has not been loaded!',
758 Analog::WARNING
759 );
760 }
761 return false;
762 } else {
763 return count($this->children) > 0;
764 }
765 }
766
767 /**
768 * Get row class related to current fee status
769 *
770 * @param boolean $public we want the class for public pages
771 *
772 * @return string the class to apply
773 */
774 public function getRowClass(bool $public = false): string
775 {
776 $strclass = ($this->isActive()) ? 'active-account' : 'inactive-account';
777 if ($public === false) {
778 $strclass .= $this->row_classes ?? '';
779 }
780 return $strclass;
781 }
782
783 /**
784 * Get current member due status
785 *
786 * @return string i18n string representing state of due
787 */
788 public function getDues(): string
789 {
790 $ret = '';
791 $never_contributed = false;
792 $now = new DateTime();
793 // To count the days remaining, the next begin date is required.
794 if (!isset($this->due_date)) {
795 $this->due_date = $now->format('Y-m-d');
796 $never_contributed = true;
797 }
798 $due_date = new DateTime($this->due_date);
799 $next_begin_date = clone $due_date;
800 $next_begin_date->add(new DateInterval('P1D'));
801 $date_diff = $now->diff($next_begin_date);
802 if ($this->isDueFree()) {
803 $ret = _T("Freed of dues");
804 } elseif ($never_contributed === true) {
805 if ($this->active) {
806 $patterns = array('/%days/', '/%date/');
807 $cdate = new DateTime($this->creation_date);
808 if (!isset($this->oldness)) {
809 $this->checkDues();
810 }
811 $replace = array(
812 $this->oldness,
813 $cdate->format(__("Y-m-d"))
814 );
815
816 $ret = preg_replace(
817 $patterns,
818 $replace,
819 _T("Never contributed: Registered %days days ago (since %date)")
820 );
821 } else {
822 $ret = _T("Never contributed");
823 }
824 // Last active or first expired day
825 } elseif ($this->days_remaining === 0) {
826 if ($date_diff->invert == 0) {
827 $ret = _T("Last day!");
828 } else {
829 $ret = _T("Late since today!");
830 }
831 // Active
832 } elseif ($date_diff->invert == 0 && $this->days_remaining > 0) {
833 $patterns = array('/%days/', '/%date/');
834 $replace = array(
835 $this->days_remaining,
836 $due_date->format(__("Y-m-d"))
837 );
838 $ret = preg_replace(
839 $patterns,
840 $replace,
841 _T("%days days remaining (ending on %date)")
842 );
843 // Expired
844 } elseif ($date_diff->invert == 1 && $this->days_remaining > 0) {
845 $patterns = array('/%days/', '/%date/');
846 $replace = array(
847 // We need the number of days expired, not the number of days remaining.
848 $this->days_remaining + 1,
849 $due_date->format(__("Y-m-d"))
850 );
851 if ($this->active) {
852 $ret = preg_replace(
853 $patterns,
854 $replace,
855 _T("Late of %days days (since %date)")
856 );
857 } else {
858 $ret = _T("No longer member");
859 }
860 }
861
862 return $ret;
863 }
864
865 /**
866 * Is member a sponsor for current period?
867 *
868 * @return bool
869 */
870 public function isSponsor(): bool
871 {
872 global $preferences;
873
874 $date_now = new DateTime();
875
876 //calculate begin date of period
877 if ($preferences->pref_beg_membership != '') { //classical membership date + 1 year
878 list($j, $m) = explode('/', $preferences->pref_beg_membership);
879 $sdate = new DateTime($date_now->format('Y') . '-' . $m . '-' . $j);
880 } elseif ($preferences->pref_membership_ext != '') { //classical membership date + N months
881 $dext = new DateInterval('P' . $preferences->pref_membership_ext . 'M');
882 $sdate = $date_now->sub($dext);
883 } else {
884 throw new \RuntimeException(
885 'Unable to define sponsoring start date; none of pref_beg_membership nor pref_membership_ext are defined!'
886 );
887 }
888
889 //date_debut_cotis because member can ask for his donation ot be recorded for next year
890 $select = $this->zdb->select(Contribution::TABLE, 'c');
891 $select
892 ->columns(
893 array(
894 'count' => new Expression('COUNT(*)')
895 )
896 )
897 ->join(
898 array(
899 'ct' => PREFIX_DB . ContributionsTypes::TABLE),
900 'c.' . ContributionsTypes::PK . '=ct.' . ContributionsTypes::PK,
901 array()
902 )
903 ->where(
904 [
905 'id_adh' => $this->id,
906 'cotis_extension' => 0 //donations only
907 ]
908 )
909 ->where->greaterThanOrEqualTo(
910 'date_debut_cotis',
911 $sdate->format('Y-m-d')
912 );
913
914 $results = $this->zdb->execute($select);
915 $result = $results->current();
916
917 return (int)$result->count > 0;
918 }
919
920 /**
921 * Retrieve Full name and surname for the specified member id
922 *
923 * @param Db $zdb Database instance
924 * @param integer $id Member id
925 * @param boolean $wid Add member id
926 * @param boolean $wnick Add member nickname
927 *
928 * @return string formatted Name and Surname
929 */
930 public static function getSName(Db $zdb, int $id, bool $wid = false, bool $wnick = false): string
931 {
932 try {
933 $select = $zdb->select(self::TABLE);
934 $select->where([self::PK => $id]);
935
936 $results = $zdb->execute($select);
937 $row = $results->current();
938 return self::getNameWithCase(
939 $row->nom_adh,
940 $row->prenom_adh,
941 false,
942 ($wid === true ? $row->id_adh : false),
943 ($wnick === true ? $row->pseudo_adh : false)
944 );
945 } catch (Throwable $e) {
946 Analog::log(
947 'Cannot get formatted name for member form id `' . $id . '` | ' .
948 $e->getMessage(),
949 Analog::WARNING
950 );
951 throw $e;
952 }
953 }
954
955 /**
956 * Get member name with correct case
957 *
958 * @param ?string $name Member name
959 * @param ?string $surname Member surname
960 * @param false|Title $title Member title to show or false
961 * @param false|integer $id Member id to display or false
962 * @param false|string $nick Member nickname to display or false
963 *
964 * @return string
965 */
966 public static function getNameWithCase(
967 ?string $name,
968 ?string $surname,
969 Title|bool $title = false,
970 int|bool $id = false,
971 string|bool $nick = false
972 ): string {
973 $str = '';
974
975 if ($title instanceof Title) {
976 $str .= $title->tshort . ' ';
977 }
978
979 $str .= mb_strtoupper($name ?? '', 'UTF-8') . ' ' .
980 ucwords(mb_strtolower($surname ?? '', 'UTF-8'), " \t\r\n\f\v-_|");
981
982 if ($id !== false || !empty($nick)) {
983 $str .= ' (';
984 }
985 if (!empty($nick)) {
986 $str .= $nick;
987 }
988 if ($id !== false) {
989 if (!empty($nick)) {
990 $str .= ', ';
991 }
992 $str .= $id;
993 }
994 if ($id !== false || !empty($nick)) {
995 $str .= ')';
996 }
997 return strip_tags($str);
998 }
999
1000 /**
1001 * Change password for a given user
1002 *
1003 * @param Db $zdb Database instance
1004 * @param integer $id_adh Member identifier
1005 * @param string $pass New password
1006 *
1007 * @return boolean
1008 */
1009 public static function updatePassword(Db $zdb, int $id_adh, string $pass): bool
1010 {
1011 try {
1012 $cpass = password_hash($pass, PASSWORD_BCRYPT);
1013
1014 $update = $zdb->update(self::TABLE);
1015 $update->set(
1016 array('mdp_adh' => $cpass)
1017 )->where([self::PK => $id_adh]);
1018 $zdb->execute($update);
1019 Analog::log(
1020 'Password for `' . $id_adh . '` has been updated.',
1021 Analog::DEBUG
1022 );
1023 return true;
1024 } catch (Throwable $e) {
1025 Analog::log(
1026 'An error occurred while updating password for `' . $id_adh .
1027 '` | ' . $e->getMessage(),
1028 Analog::ERROR
1029 );
1030 throw $e;
1031 }
1032 }
1033
1034 /**
1035 * Get field label
1036 *
1037 * @param string $field Field name
1038 *
1039 * @return string
1040 */
1041 private function getFieldLabel(string $field): string
1042 {
1043 $label = $this->fields[$field]['label'] ?? '';
1044 //replace "&nbsp;"
1045 $label = str_replace('&nbsp;', ' ', $label);
1046 //remove trailing ':' and then trim
1047 $label = trim(trim($label, ':'));
1048 return $label;
1049 }
1050
1051 /**
1052 * Retrieve fields from database
1053 *
1054 * @param Db $zdb Database instance
1055 *
1056 * @return array<string>
1057 */
1058 public static function getDbFields(Db $zdb): array
1059 {
1060 $columns = $zdb->getColumns(self::TABLE);
1061 $fields = array();
1062 foreach ($columns as $col) {
1063 $fields[] = $col->getName();
1064 }
1065 return $fields;
1066 }
1067
1068 /**
1069 * Mark as self membership
1070 *
1071 * @return void
1072 */
1073 public function setSelfMembership(): void
1074 {
1075 $this->self_adh = true;
1076 }
1077
1078 /**
1079 * Is member up to date?
1080 *
1081 * @return boolean
1082 */
1083 public function isUp2Date(): bool
1084 {
1085 if (!$this->isDepEnabled('dues')) {
1086 $this->checkDues();
1087 }
1088
1089 if ($this->isDueFree()) {
1090 //member is due free, he's up-to-date.
1091 return true;
1092 } else {
1093 //let's check from due date, if present
1094 if (!isset($this->due_date)) {
1095 return false;
1096 } else {
1097 $due_date = new DateTime($this->due_date);
1098 $now = new DateTime();
1099 $now->setTime(0, 0, 0);
1100 return $due_date >= $now;
1101 }
1102 }
1103 }
1104
1105 /**
1106 * Set dependencies
1107 *
1108 * @param Preferences $preferences Preferences instance
1109 * @param array<string,mixed> $fields Members fields configuration
1110 * @param History $history History instance
1111 *
1112 * @return void
1113 */
1114 public function setDependencies(
1115 Preferences $preferences,
1116 array $fields,
1117 History $history
1118 ): void {
1119 $this->preferences = $preferences;
1120 $this->fields = $fields;
1121 $this->history = $history;
1122 }
1123
1124 /**
1125 * Check posted values validity
1126 *
1127 * @param array<string,mixed> $values All values to check, basically the $_POST array
1128 * after sending the form
1129 * @param array<string,bool> $required Array of required fields
1130 * @param array<string> $disabled Array of disabled fields
1131 *
1132 * @return true|array<string>
1133 */
1134 public function check(array $values, array $required, array $disabled): bool|array
1135 {
1136 global $login;
1137
1138 $this->errors = array();
1139
1140 //Sanitize
1141 foreach ($values as &$rawvalue) {
1142 if (is_string($rawvalue)) {
1143 $rawvalue = strip_tags($rawvalue);
1144 }
1145 }
1146
1147 $fields = self::getDbFields($this->zdb);
1148
1149 //reset company name if needed
1150 if (!isset($values['is_company'])) {
1151 unset($values['is_company']);
1152 $values['societe_adh'] = '';
1153 }
1154
1155 //no parent if checkbox was unchecked
1156 if (
1157 !isset($values['attach'])
1158 && !empty($this->id)
1159 && isset($values['parent_id'])
1160 ) {
1161 unset($values['parent_id']);
1162 }
1163
1164 if (isset($values['duplicate'])) {
1165 //if we're duplicating, keep a trace (if an error occurs)
1166 $this->duplicate = true;
1167 }
1168
1169 foreach ($fields as $key) {
1170 //first, let's sanitize values
1171 $key = strtolower($key);
1172 $prop = $this->fields[$key]['propname'];
1173
1174 if (isset($values[$key])) {
1175 $value = $values[$key];
1176 if ($value !== true && $value !== false) {
1177 //@phpstan-ignore-next-line
1178 $value = trim($value ?? '');
1179 }
1180 } elseif (empty($this->id)) {
1181 switch ($key) {
1182 case 'bool_admin_adh':
1183 case 'bool_exempt_adh':
1184 case 'bool_display_info':
1185 $value = 0;
1186 break;
1187 case 'activite_adh':
1188 //values that are set at object instantiation
1189 $value = true;
1190 break;
1191 case 'date_crea_adh':
1192 case 'sexe_adh':
1193 case 'titre_adh':
1194 case 'id_statut':
1195 case 'pref_lang':
1196 case 'parent_id':
1197 //values that are set at object instantiation
1198 $value = $this->$prop;
1199 break;
1200 case self::PK:
1201 $value = null;
1202 break;
1203 default:
1204 $value = '';
1205 break;
1206 }
1207 } else {
1208 //keep stored value on update
1209 if ($prop != 'password' || isset($values['mdp_adh']) && isset($values['mdp_adh2'])) {
1210 $value = $this->$prop;
1211 } else {
1212 $value = null;
1213 }
1214 }
1215
1216 // if the field is enabled, check it
1217 if (!in_array($key, $disabled)) {
1218 // fill up the adherent structure
1219 if ($value !== null && $value !== true && $value !== false && !is_object($value)) {
1220 $value = stripslashes($value);
1221 }
1222 $this->$prop = $value;
1223
1224 // now, check validity
1225 if ($value !== null && $value != '') {
1226 if ($key !== 'mdp_adh') {
1227 $this->validate($key, $value, $values);
1228 }
1229 } elseif (empty($this->id)) {
1230 //ensure login and password are not empty
1231 if (($key == 'login_adh' || $key == 'mdp_adh') && !isset($required[$key])) {
1232 $p = new Password($this->zdb);
1233 $generated_value = $p->makeRandomPassword(15);
1234 if ($key == 'login_adh') {
1235 //'@' is not permitted in logins
1236 $this->$prop = str_replace('@', 'a', $generated_value);
1237 } else {
1238 $this->$prop = $generated_value;
1239 }
1240 }
1241 }
1242 }
1243 }
1244
1245 //password checks need data to be previously set
1246 if (isset($values['mdp_adh'])) {
1247 $this->validate('mdp_adh', $values['mdp_adh'], $values);
1248 }
1249
1250 // missing required fields?
1251 foreach ($required as $key => $val) {
1252 $prop = $this->fields[$key]['propname'];
1253
1254 if (!isset($disabled[$key])) {
1255 $mandatory_missing = false;
1256 if (!isset($this->$prop) || $this->$prop == '') {
1257 $mandatory_missing = true;
1258 } elseif ($key === 'titre_adh' && $this->$prop == '-1') {
1259 $mandatory_missing = true;
1260 }
1261
1262 if ($mandatory_missing === true) {
1263 $this->errors[] = str_replace(
1264 '%field',
1265 '<a href="#' . $key . '">' . $this->getFieldLabel($key) . '</a>',
1266 _T("- Mandatory field %field empty.")
1267 );
1268 }
1269 }
1270 }
1271
1272 //attach to/detach from parent
1273 if (isset($values['detach_parent'])) {
1274 $this->parent = null;
1275 }
1276
1277 if ($login->isGroupManager() && !$login->isAdmin() && !$login->isStaff() && $this->parent_id !== $login->id) {
1278 if (!isset($values['groups_adh'])) {
1279 $this->errors[] = _T('You have to select a group you own!');
1280 } else {
1281 $owned_group = false;
1282 foreach ($values['groups_adh'] as $group) {
1283 list($gid) = explode('|', $group);
1284 if ($login->isGroupManager($gid)) {
1285 $owned_group = true;
1286 }
1287 }
1288 if ($owned_group === false) {
1289 $this->errors[] = _T('You have to select a group you own!');
1290 }
1291 }
1292 }
1293
1294 $this->dynamicsCheck($values, $required, $disabled);
1295 $this->checkSocials($values);
1296
1297 if (count($this->errors) > 0) {
1298 Analog::log(
1299 'Some errors has been thew attempting to edit/store a member' . "\n" .
1300 print_r($this->errors, true),
1301 Analog::ERROR
1302 );
1303 return $this->errors;
1304 } else {
1305 $this->checkDues();
1306
1307 Analog::log(
1308 'Member checked successfully.',
1309 Analog::DEBUG
1310 );
1311 return true;
1312 }
1313 }
1314
1315 /**
1316 * Validate data for given key
1317 * Set valid data in current object, also resets errors list
1318 *
1319 * @param string $field Field name
1320 * @param mixed $value Value we want to set
1321 * @param array<string,mixed> $values All values, for some references
1322 *
1323 * @return void
1324 */
1325 public function validate(string $field, $value, array $values): void
1326 {
1327 global $preferences;
1328
1329 $prop = $this->fields[$field]['propname'];
1330
1331 if ($value === null || (is_string($value) && trim($value) == '')) {
1332 //empty values are OK
1333 $this->$prop = $value;
1334 return;
1335 }
1336
1337 switch ($field) {
1338 // dates
1339 case 'date_crea_adh':
1340 case 'date_modif_adh_':
1341 case 'ddn_adh':
1342 case 'date_echeance':
1343 try {
1344 $d = DateTime::createFromFormat(__("Y-m-d"), $value);
1345 if ($d === false) {
1346 //try with non localized date
1347 $d = DateTime::createFromFormat("Y-m-d", $value);
1348 if ($d === false) {
1349 throw new \Exception('Incorrect format');
1350 }
1351 }
1352
1353 if ($field === 'ddn_adh') {
1354 $now = new DateTime();
1355 $now->setTime(0, 0, 0);
1356 $d->setTime(0, 0, 0);
1357
1358 $diff = $now->diff($d);
1359 $days = (int)$diff->format('%R%a');
1360 if ($days >= 0) {
1361 $this->errors[] = _T('- Birthdate must be set in the past!');
1362 }
1363
1364 $years = (int)$diff->format('%R%Y');
1365 if ($years <= -200) {
1366 $this->errors[] = str_replace(
1367 '%years',
1368 (string)($years * -1),
1369 _T('- Members must be less than 200 years old (currently %years)!')
1370 );
1371 }
1372 }
1373 $this->$prop = $d->format('Y-m-d');
1374 } catch (Throwable $e) {
1375 Analog::log(
1376 'Wrong date format. field: ' . $field .
1377 ', value: ' . $value . ', expected fmt: ' .
1378 __("Y-m-d") . ' | ' . $e->getMessage(),
1379 Analog::INFO
1380 );
1381 $this->errors[] = sprintf(
1382 //TRANS %1$s is the expected dat format, %2$s the field label
1383 _T('- Wrong date format (%1$s) for %2$s!'),
1384 __("Y-m-d"),
1385 $this->getFieldLabel($field)
1386 );
1387 }
1388 break;
1389 case 'titre_adh':
1390 if ($value !== '') {
1391 if ($value == '-1') {
1392 $this->$prop = null;
1393 } elseif (!$value instanceof Title) {
1394 $this->$prop = new Title((int)$value);
1395 }
1396 } else {
1397 $this->$prop = null;
1398 }
1399 break;
1400 case 'email_adh':
1401 if (!GaletteMail::isValidEmail($value)) {
1402 $this->errors[] = _T("- Non-valid E-Mail address!") .
1403 ' (' . $this->getFieldLabel($field) . ')';
1404 }
1405
1406 try {
1407 $select = $this->zdb->select(self::TABLE);
1408 $select->columns(
1409 array(self::PK)
1410 )->where(array('email_adh' => $value));
1411 if (!empty($this->id)) {
1412 $select->where->notEqualTo(
1413 self::PK,
1414 $this->id
1415 );
1416 }
1417
1418 $results = $this->zdb->execute($select);
1419 if ($results->count() !== 0) {
1420 $this->errors[] = _T("- This E-Mail address is already used by another member!");
1421 }
1422 } catch (Throwable $e) {
1423 Analog::log(
1424 'An error occurred checking member email uniqueness.',
1425 Analog::ERROR
1426 );
1427 $this->errors[] = _T("An error has occurred while looking if login already exists.");
1428 }
1429 break;
1430 case 'login_adh':
1431 /** FIXME: add a preference for login length */
1432 if (strlen($value) < 2) {
1433 $this->errors[] = str_replace(
1434 '%i',
1435 '2',
1436 _T("- The username must be composed of at least %i characters!")
1437 );
1438 } else {
1439 //check if login does not contain the @ character
1440 if (strpos($value, '@') != false) {
1441 $this->errors[] = _T("- The username cannot contain the @ character");
1442 } else {
1443 //check if login is already taken
1444 try {
1445 $select = $this->zdb->select(self::TABLE);
1446 $select->columns(
1447 array(self::PK)
1448 )->where(array('login_adh' => $value));
1449 if (!empty($this->id)) {
1450 $select->where->notEqualTo(
1451 self::PK,
1452 $this->id
1453 );
1454 }
1455
1456 $results = $this->zdb->execute($select);
1457 if (
1458 $results->count() !== 0
1459 || $value == $preferences->pref_admin_login
1460 ) {
1461 $this->errors[] = _T("- This username is already in use, please choose another one!");
1462 }
1463 } catch (Throwable $e) {
1464 Analog::log(
1465 'An error occurred checking member login uniqueness.',
1466 Analog::ERROR
1467 );
1468 $this->errors[] = _T("An error has occurred while looking if login already exists.");
1469 }
1470 }
1471 }
1472 break;
1473 case 'mdp_adh':
1474 if (
1475 $this->self_adh !== true
1476 && (!isset($values['mdp_adh2'])
1477 || $values['mdp_adh2'] != $value)
1478 ) {
1479 $this->errors[] = _T("- The passwords don't match!");
1480 } elseif (
1481 $this->self_adh === true
1482 && !crypt($value, $values['mdp_crypt']) == $values['mdp_crypt']
1483 ) {
1484 $this->errors[] = _T("Password misrepeated: ");
1485 } else {
1486 $pinfos = password_get_info($value);
1487 //check if value is already a hash
1488 if ($pinfos['algo'] == 0) {
1489 $this->$prop = password_hash(
1490 $value,
1491 PASSWORD_BCRYPT
1492 );
1493
1494 $pwcheck = new \Galette\Util\Password($preferences);
1495 $pwcheck->setAdherent($this);
1496 if (!$pwcheck->isValid($value)) {
1497 $this->errors = array_merge(
1498 $this->errors,
1499 $pwcheck->getErrors()
1500 );
1501 }
1502 }
1503 }
1504 break;
1505 case 'id_statut':
1506 try {
1507 $this->$prop = (int)$value;
1508 //check if status exists
1509 $select = $this->zdb->select(Status::TABLE);
1510 $select->where([Status::PK => $value]);
1511
1512 $results = $this->zdb->execute($select);
1513 $result = $results->current();
1514 if (!$result) {
1515 $this->errors[] = str_replace(
1516 '%id',
1517 $value,
1518 _T("Status #%id does not exists in database.")
1519 );
1520 break;
1521 }
1522 } catch (Throwable $e) {
1523 Analog::log(
1524 'An error occurred checking status existence: ' . $e->getMessage(),
1525 Analog::ERROR
1526 );
1527 $this->errors[] = _T("An error has occurred while looking if status does exists.");
1528 }
1529 break;
1530 case 'sexe_adh':
1531 if (in_array($value, [self::NC, self::MAN, self::WOMAN])) {
1532 $this->$prop = (int)$value;
1533 } else {
1534 $this->errors[] = _T("Gender %gender does not exists!");
1535 }
1536 break;
1537 case 'parent_id':
1538 $pid = ($value instanceof Adherent) ? (int)$value->id : (int)$value;
1539 if ($pid === $this->id) {
1540 $this->errors[] = _T("A member cannot be its own parent!");
1541 $this->$prop = null;
1542 } else {
1543 $this->$prop = $pid;
1544 $this->loadParent();
1545 }
1546 break;
1547 }
1548 }
1549
1550 /**
1551 * Store the member
1552 *
1553 * @return boolean
1554 */
1555 public function store(): bool
1556 {
1557 global $hist, $emitter, $login;
1558 $event = null;
1559
1560 if (!$login->isAdmin() && !$login->isStaff() && !$login->isGroupManager() && $this->id == '') {
1561 if ($this->preferences->pref_bool_create_member) {
1562 $this->parent = $login->id;
1563 }
1564 }
1565
1566 try {
1567 $values = array();
1568 $fields = self::getDbFields($this->zdb);
1569
1570 foreach ($fields as $field) {
1571 if (
1572 $field !== 'date_modif_adh'
1573 || empty($this->id)
1574 ) {
1575 $prop = $this->fields[$field]['propname'];
1576 if (
1577 ($field === 'bool_admin_adh'
1578 || $field === 'bool_exempt_adh'
1579 || $field === 'bool_display_info'
1580 || $field === 'activite_adh')
1581 && $this->$prop === false
1582 ) {
1583 //Handle booleans for postgres ; bugs #18899 and #19354
1584 $values[$field] = $this->zdb->isPostgres() ? 'false' : 0;
1585 } elseif ($field === 'parent_id') {
1586 //handle parents
1587 if (!isset($this->parent)) {
1588 $values['parent_id'] = new Expression('NULL');
1589 } elseif ($this->parent instanceof Adherent) {
1590 $values['parent_id'] = $this->parent->id;
1591 } else {
1592 $values['parent_id'] = $this->parent;
1593 }
1594 } else {
1595 $values[$field] = $this->$prop;
1596 }
1597 }
1598 }
1599
1600 //an empty value will cause date to be set to 1901-01-01, a null
1601 //will result in 0000-00-00. We want a database NULL value here.
1602 if (!$this->birthdate) {
1603 $values['ddn_adh'] = new Expression('NULL');
1604 }
1605 if (!$this->due_date) {
1606 $values['date_echeance'] = new Expression('NULL');
1607 }
1608
1609 if ($this->title instanceof Title) {
1610 $values['titre_adh'] = $this->title->id;
1611 } else {
1612 $values['titre_adh'] = new Expression('NULL');
1613 }
1614
1615 if (!$this->parent) {
1616 $values['parent_id'] = new Expression('NULL');
1617 }
1618
1619 if (!$this->number) {
1620 $values['num_adh'] = new Expression('NULL');
1621 }
1622
1623 //fields that cannot be null
1624 $notnull = [
1625 'surname' => 'prenom_adh',
1626 'nickname' => 'pseudo_adh',
1627 'address' => 'adresse_adh',
1628 'zipcode' => 'cp_adh',
1629 'town' => 'ville_adh',
1630 'region' => 'region_adh'
1631 ];
1632 foreach ($notnull as $prop => $field) {
1633 if (!isset($this->$prop) || $this->$prop === null) {
1634 $values[$field] = '';
1635 }
1636 }
1637
1638 if (empty($this->id)) {
1639 //we're inserting a new member
1640 unset($values[self::PK]);
1641 //set modification date
1642 $this->modification_date = date('Y-m-d');
1643 $values['date_modif_adh'] = $this->modification_date;
1644
1645 $insert = $this->zdb->insert(self::TABLE);
1646 $insert->values($values);
1647 $add = $this->zdb->execute($insert);
1648 if ($add->count() > 0) {
1649 $this->id = $this->zdb->getLastGeneratedValue($this);
1650 $this->picture = new Picture($this->id);
1651 // logging
1652 if ($this->self_adh) {
1653 $hist->add(
1654 _T("Self_subscription as a member: ") .
1655 $this->getNameWithCase($this->name, $this->surname),
1656 $this->sname
1657 );
1658 } else {
1659 $hist->add(
1660 _T("Member card added"),
1661 $this->sname
1662 );
1663 }
1664
1665 $event = $this->getAddEventName();
1666 } else {
1667 $hist->add(_T("Fail to add new member."));
1668 throw new \Exception(
1669 'An error occurred inserting new member!'
1670 );
1671 }
1672 } else {
1673 //we're editing an existing member
1674 if (!$this->isDueFree()) {
1675 // deadline
1676 $due_date = Contribution::getDueDate($this->zdb, $this->id);
1677 if ($due_date) {
1678 $values['date_echeance'] = $due_date;
1679 }
1680 }
1681
1682 if (!$this->password) {
1683 unset($values['mdp_adh']);
1684 }
1685
1686 $update = $this->zdb->update(self::TABLE);
1687 $update->set($values);
1688 $update->where([self::PK => $this->id]);
1689
1690 $edit = $this->zdb->execute($update);
1691
1692 //edit == 0 does not mean there were an error, but that there
1693 //were nothing to change
1694 if ($edit->count() > 0) {
1695 $this->updateModificationDate();
1696 $hist->add(
1697 _T("Member card updated"),
1698 $this->sname
1699 );
1700 }
1701 $event = $this->getEditEventName();
1702 }
1703
1704 //dynamic fields
1705 $this->dynamicsStore();
1706 $this->storeSocials($this->id);
1707
1708 //send event at the end of process, once all has been stored
1709 if ($event !== null && $this->areEventsEnabled()) {
1710 $emitter->dispatch(new GaletteEvent($event, $this));
1711 }
1712 return true;
1713 } catch (Throwable $e) {
1714 Analog::log(
1715 'Something went wrong :\'( | ' . $e->getMessage() . "\n" .
1716 $e->getTraceAsString(),
1717 Analog::ERROR
1718 );
1719 throw $e;
1720 }
1721 }
1722
1723 /**
1724 * Update member modification date
1725 *
1726 * @return void
1727 */
1728 private function updateModificationDate(): void
1729 {
1730 try {
1731 $modif_date = date('Y-m-d');
1732 $update = $this->zdb->update(self::TABLE);
1733 $update->set(
1734 array('date_modif_adh' => $modif_date)
1735 )->where([self::PK => $this->id]);
1736
1737 $this->zdb->execute($update);
1738 $this->modification_date = $modif_date;
1739 } catch (Throwable $e) {
1740 Analog::log(
1741 'Something went wrong updating modif date :\'( | ' .
1742 $e->getMessage() . "\n" . $e->getTraceAsString(),
1743 Analog::ERROR
1744 );
1745 throw $e;
1746 }
1747 }
1748
1749 /**
1750 * Global getter method
1751 *
1752 * @param string $name name of the property we want to retrieve
1753 *
1754 * @return mixed
1755 */
1756 public function __get(string $name)
1757 {
1758 $forbidden = array(
1759 'admin', 'staff', 'due_free', 'appears_in_list', 'active',
1760 'row_classes', 'oldness', 'duplicate', 'groups', 'managed_groups'
1761 );
1762 if (!defined('GALETTE_TESTS')) {
1763 $forbidden[] = 'password'; //keep that for tests only
1764 }
1765
1766 $virtuals = array(
1767 'sadmin', 'sstaff', 'sdue_free', 'sappears_in_list', 'sactive',
1768 'stitle', 'sstatus', 'sfullname', 'sname', 'saddress',
1769 'rbirthdate', 'sgender', 'contribstatus',
1770 );
1771
1772 $socials = array('website', 'msn', 'jabber', 'icq');
1773
1774 if (in_array($name, $forbidden)) {
1775 Analog::log(
1776 'Calling property "' . $name . '" directly is discouraged.',
1777 Analog::WARNING
1778 );
1779 switch ($name) {
1780 case 'admin':
1781 return $this->isAdmin();
1782 case 'staff':
1783 return $this->isStaff();
1784 case 'due_free':
1785 return $this->isDueFree();
1786 case 'appears_in_list':
1787 return $this->appearsInMembersList();
1788 case 'active':
1789 return $this->isActive();
1790 case 'duplicate':
1791 return $this->isDuplicate();
1792 case 'groups':
1793 return $this->getGroups();
1794 case 'managed_groups':
1795 return $this->getManagedGroups();
1796 default:
1797 throw new \RuntimeException("Call to __get for '$name' is forbidden!");
1798 }
1799 }
1800
1801 if (in_array($name, $virtuals)) {
1802 switch ($name) {
1803 case 'sadmin':
1804 return (($this->isAdmin()) ? _T("Yes") : _T("No"));
1805 case 'sdue_free':
1806 return (($this->isDueFree()) ? _T("Yes") : _T("No"));
1807 case 'sappears_in_list':
1808 return (($this->appearsInMembersList()) ? _T("Yes") : _T("No"));
1809 case 'sstaff':
1810 return (($this->isStaff()) ? _T("Yes") : _T("No"));
1811 case 'sactive':
1812 return (($this->isActive()) ? _T("Active") : _T("Inactive"));
1813 case 'stitle':
1814 if (isset($this->title) && $this->title instanceof Title) {
1815 return $this->title->tshort;
1816 } else {
1817 return null;
1818 }
1819 case 'sstatus':
1820 $status = new Status($this->zdb);
1821 return $status->getLabel($this->status);
1822 case 'sfullname':
1823 return $this->getNameWithCase(
1824 $this->name ?? '',
1825 $this->surname ?? '',
1826 ($this->title ?? false)
1827 );
1828 case 'saddress':
1829 return $this->address;
1830 case 'sname':
1831 return $this->getNameWithCase($this->name ?? '', $this->surname ?? '');
1832 case 'rbirthdate':
1833 return $this->birthdate ?? null;
1834 case 'sgender':
1835 switch ($this->gender) {
1836 case self::MAN:
1837 return _T('Man');
1838 case self::WOMAN:
1839 return _T('Woman');
1840 default:
1841 return _T('Unspecified');
1842 }
1843 case 'contribstatus':
1844 return $this->getDues();
1845 }
1846 }
1847
1848 //for backward compatibility
1849 if (in_array($name, $socials)) {
1850 $values = Social::getListForMember($this->id, $name);
1851 return $values[0] ?? null;
1852 }
1853
1854 switch ($name) {
1855 case 'id':
1856 case 'id_statut':
1857 if (isset($this->$name) && $this->$name !== null) {
1858 return (int)$this->$name;
1859 } else {
1860 return null;
1861 }
1862 case 'address':
1863 return $this->$name ?? '';
1864 case 'birthdate':
1865 case 'creation_date':
1866 case 'modification_date':
1867 case 'due_date':
1868 if (isset($this->$name) && $this->$name != '') {
1869 try {
1870 $d = new DateTime($this->$name);
1871 return $d->format(__("Y-m-d"));
1872 } catch (Throwable $e) {
1873 //oops, we've got a bad date :/
1874 Analog::log(
1875 'Bad date (' . $this->$name . ') | ' .
1876 $e->getMessage(),
1877 Analog::INFO
1878 );
1879 return $this->$name;
1880 }
1881 }
1882 return null;
1883 case 'parent_id':
1884 return ($this->parent instanceof Adherent) ? $this->parent->id : (int)$this->parent;
1885 default:
1886 if (!property_exists($this, $name)) {
1887 Analog::log(
1888 "Unknown property '$name'",
1889 Analog::WARNING
1890 );
1891 return null;
1892 } else {
1893 if (isset($this->$name)) {
1894 return $this->$name;
1895 }
1896 return null;
1897 }
1898 }
1899 }
1900
1901 /**
1902 * Global isset method
1903 * Required for twig to access properties via __get
1904 *
1905 * @param string $name name of the property we want to retrieve
1906 *
1907 * @return bool
1908 */
1909 public function __isset(string $name): bool
1910 {
1911 $forbidden = array(
1912 'admin', 'staff', 'due_free', 'appears_in_list', 'active',
1913 'row_classes', 'oldness', 'duplicate', 'groups', 'managed_groups'
1914 );
1915 if (!defined('GALETTE_TESTS')) {
1916 $forbidden[] = 'password'; //keep that for tests only
1917 }
1918
1919 $virtuals = array(
1920 'sadmin', 'sstaff', 'sdue_free', 'sappears_in_list', 'sactive',
1921 'stitle', 'sstatus', 'sfullname', 'sname', 'saddress',
1922 'rbirthdate', 'sgender', 'contribstatus',
1923 );
1924
1925 $socials = array('website', 'msn', 'jabber', 'icq');
1926
1927 if (in_array($name, $forbidden)) {
1928 Analog::log(
1929 'Calling property "' . $name . '" directly is discouraged.',
1930 Analog::WARNING
1931 );
1932 switch ($name) {
1933 case 'admin':
1934 case 'staff':
1935 case 'due_free':
1936 case 'appears_in_list':
1937 case 'active':
1938 case 'duplicate':
1939 case 'groups':
1940 case 'managed_groups':
1941 return true;
1942 }
1943
1944 return false;
1945 }
1946
1947 if (in_array($name, $virtuals)) {
1948 return true;
1949 }
1950
1951 //for backward compatibility
1952 if (in_array($name, $socials)) {
1953 return true;
1954 }
1955
1956 switch ($name) {
1957 case 'id':
1958 case 'id_statut':
1959 case 'address':
1960 case 'birthdate':
1961 case 'creation_date':
1962 case 'modification_date':
1963 case 'due_date':
1964 case 'parent_id':
1965 return true;
1966 default:
1967 return property_exists($this, $name);
1968 }
1969 }
1970
1971 /**
1972 * Get member email
1973 * If member does not have an email address, but is attached to
1974 * another member, we'll take information from its parent.
1975 *
1976 * @return string
1977 */
1978 public function getEmail(): string
1979 {
1980 $email = $this->email;
1981 if (empty($email) && $this->hasParent()) {
1982 $this->loadParent();
1983 $email = $this->parent->email;
1984 }
1985
1986 return $email ?? '';
1987 }
1988
1989 /**
1990 * Get member address.
1991 * If member does not have an address, but is attached to another member, we'll take information from its parent.
1992 *
1993 * @return string
1994 */
1995 public function getAddress(): string
1996 {
1997 $address = $this->address;
1998 if (empty($address) && $this->hasParent()) {
1999 $this->loadParent();
2000 $address = $this->parent->address;
2001 }
2002
2003 return $address ?? '';
2004 }
2005
2006 /**
2007 * Get member zipcode.
2008 * If member does not have an address, but is attached to another member, we'll take information from its parent.
2009 *
2010 * @return string
2011 */
2012 public function getZipcode(): string
2013 {
2014 $address = $this->address;
2015 $zip = $this->zipcode;
2016 if (empty($address) && $this->hasParent()) {
2017 $this->loadParent();
2018 $zip = $this->parent->zipcode;
2019 }
2020
2021 return $zip ?? '';
2022 }
2023
2024 /**
2025 * Get member town.
2026 * If member does not have an address, but is attached to another member, we'll take information from its parent.
2027 *
2028 * @return string
2029 */
2030 public function getTown(): string
2031 {
2032 $address = $this->address;
2033 $town = $this->town;
2034 if (empty($address) && $this->hasParent()) {
2035 $this->loadParent();
2036 $town = $this->parent->town;
2037 }
2038
2039 return $town ?? '';
2040 }
2041
2042 /**
2043 * Get member region.
2044 * If member does not have an address, but is attached to another member, we'll take information from its parent.
2045 *
2046 * @return string
2047 */
2048 public function getRegion(): string
2049 {
2050 $address = $this->address;
2051 $region = $this->region;
2052 if (empty($address) && $this->hasParent()) {
2053 $this->loadParent();
2054 $region = $this->parent->region;
2055 }
2056
2057 return $region ?? '';
2058 }
2059
2060 /**
2061 * Get member country.
2062 * If member does not have an address, but is attached to another member, we'll take information from its parent.
2063 *
2064 * @return string
2065 */
2066 public function getCountry(): string
2067 {
2068 $address = $this->address;
2069 $country = $this->country;
2070 if (empty($address) && $this->hasParent()) {
2071 $this->loadParent();
2072 $country = $this->parent->country;
2073 }
2074
2075 return $country ?? '';
2076 }
2077
2078 /**
2079 * Get member age
2080 *
2081 * @return string
2082 */
2083 public function getAge(): string
2084 {
2085 if (!isset($this->birthdate) && $this->birthdate == null) {
2086 return '';
2087 }
2088
2089 $d = DateTime::createFromFormat('Y-m-d', $this->birthdate);
2090 if ($d === false) {
2091 Analog::log(
2092 'Invalid birthdate: ' . $this->birthdate,
2093 Analog::ERROR
2094 );
2095 return '';
2096 }
2097
2098 return str_replace(
2099 '%age',
2100 (string)$d->diff(new DateTime())->y,
2101 _T(' (%age years old)')
2102 );
2103 }
2104
2105 /**
2106 * Get parent inherited fields
2107 *
2108 * @return array<string>
2109 */
2110 public function getParentFields(): array
2111 {
2112 return $this->parent_fields;
2113 }
2114
2115 /**
2116 * Handle files (photo and dynamics files)
2117 *
2118 * @param array<string,mixed> $files Files sent
2119 * @param ?array<string,mixed> $cropping Cropping properties
2120 *
2121 * @return array<string>|true
2122 */
2123 public function handleFiles(array $files, array $cropping = null): array|bool
2124 {
2125 $this->errors = [];
2126 // picture upload
2127 if (isset($files['photo'])) {
2128 if ($files['photo']['error'] === UPLOAD_ERR_OK) {
2129 if ($files['photo']['tmp_name'] != '') {
2130 if (is_uploaded_file($files['photo']['tmp_name'])) {
2131 if ($this->preferences->pref_force_picture_ratio == 1 && isset($cropping)) {
2132 $res = $this->picture->store($files['photo'], false, $cropping);
2133 } else {
2134 $res = $this->picture->store($files['photo']);
2135 }
2136 if ($res < 0) {
2137 $this->errors[]
2138 = $this->picture->getErrorMessage($res);
2139 }
2140 }
2141 }
2142 } elseif ($files['photo']['error'] !== UPLOAD_ERR_NO_FILE) {
2143 Analog::log(
2144 $this->picture->getPhpErrorMessage($files['photo']['error']),
2145 Analog::WARNING
2146 );
2147 $this->errors[] = $this->picture->getPhpErrorMessage(
2148 $files['photo']['error']
2149 );
2150 }
2151 }
2152 $this->dynamicsFiles($files);
2153
2154 if (count($this->errors) > 0) {
2155 Analog::log(
2156 'Some errors has been thew attempting to edit/store a member files' . "\n" .
2157 print_r($this->errors, true),
2158 Analog::ERROR
2159 );
2160 return $this->errors;
2161 } else {
2162 return true;
2163 }
2164 }
2165
2166 /**
2167 * Set member as duplicate
2168 *
2169 * @return void
2170 */
2171 public function setDuplicate(): void
2172 {
2173 //mark as duplicated
2174 $this->duplicate = true;
2175 $infos = $this->others_infos_admin;
2176 $this->others_infos_admin = str_replace(
2177 ['%name', '%id'],
2178 [$this->sname, $this->id],
2179 _T('Duplicated from %name (%id)')
2180 );
2181 if (!empty($infos)) {
2182 $this->others_infos_admin .= "\n" . $infos;
2183 }
2184 //drop id_adh
2185 $this->id = null;
2186 //drop email, must be unique
2187 $this->email = null;
2188 //drop creation date
2189 $this->creation_date = date("Y-m-d");
2190 //drop login
2191 $this->login = null;
2192 //reset picture
2193 $this->picture = new Picture();
2194 //remove birthdate
2195 $this->birthdate = null;
2196 //remove surname
2197 $this->surname = null;
2198 //not admin
2199 $this->admin = false;
2200 //not due free
2201 $this->due_free = false;
2202 }
2203
2204 /**
2205 * Get current errors
2206 *
2207 * @return array<string>
2208 */
2209 public function getErrors(): array
2210 {
2211 return $this->errors;
2212 }
2213
2214 /**
2215 * Get user groups
2216 *
2217 * @return array<int, Group>
2218 */
2219 public function getGroups(): array
2220 {
2221 if (!$this->isDepEnabled('groups')) {
2222 $this->loadGroups();
2223 }
2224 return $this->groups;
2225 }
2226
2227 /**
2228 * Get user managed groups
2229 *
2230 * @return array<int, Group>
2231 */
2232 public function getManagedGroups(): array
2233 {
2234 if (!$this->isDepEnabled('groups')) {
2235 $this->loadGroups();
2236 }
2237 return $this->managed_groups;
2238 }
2239
2240 /**
2241 * Can current logged-in user create member
2242 *
2243 * @param Login $login Login instance
2244 *
2245 * @return boolean
2246 */
2247 public function canCreate(Login $login): bool
2248 {
2249 global $preferences;
2250
2251 if (isset($this->id) && $login->id == $this->id || $login->isAdmin() || $login->isStaff()) {
2252 return true;
2253 }
2254
2255 if ($preferences->pref_bool_groupsmanagers_create_member && $login->isGroupManager()) {
2256 return true;
2257 }
2258
2259 if ($preferences->pref_bool_create_member && $login->isLogged()) {
2260 return true;
2261 }
2262
2263 return false;
2264 }
2265
2266 /**
2267 * Can current logged-in user edit member
2268 *
2269 * @param Login $login Login instance
2270 *
2271 * @return boolean
2272 */
2273 public function canEdit(Login $login): bool
2274 {
2275 global $preferences;
2276
2277 //admin and staff users can edit, as well as member itself
2278 if (isset($this->id) && $login->id == $this->id || $login->isAdmin() || $login->isStaff()) {
2279 return true;
2280 }
2281
2282 //parent can edit their child cards
2283 if ($this->hasParent() && $this->parent_id === $login->id) {
2284 return true;
2285 }
2286
2287 //group managers can edit members of groups they manage when pref is on
2288 if ($preferences->pref_bool_groupsmanagers_edit_member && $login->isGroupManager()) {
2289 foreach ($this->getGroups() as $g) {
2290 if ($login->isGroupManager($g->getId())) {
2291 return true;
2292 }
2293 }
2294 }
2295
2296 return false;
2297 }
2298
2299 /**
2300 * Can current logged-in user display member
2301 *
2302 * @param Login $login Login instance
2303 *
2304 * @return boolean
2305 */
2306 public function canShow(Login $login): bool
2307 {
2308 //group managers can show members of groups they manage
2309 if ($login->isGroupManager()) {
2310 foreach ($this->getGroups() as $g) {
2311 if ($login->isGroupManager($g->getId())) {
2312 return true;
2313 }
2314 }
2315 }
2316
2317 return $this->canEdit($login);
2318 }
2319
2320 /**
2321 * Are we currently duplicated a member?
2322 *
2323 * @return boolean
2324 */
2325 public function isDuplicate(): bool
2326 {
2327 return $this->duplicate;
2328 }
2329
2330 /**
2331 * Flag creation mail sending
2332 *
2333 * @param boolean $send True (default) to send creation email
2334 *
2335 * @return Adherent
2336 */
2337 public function setSendmail(bool $send = true): self
2338 {
2339 $this->sendmail = $send;
2340 return $this;
2341 }
2342
2343 /**
2344 * Should we send administrative emails to member?
2345 *
2346 * @return boolean
2347 */
2348 public function sendEMail(): bool
2349 {
2350 return $this->sendmail;
2351 }
2352
2353 /**
2354 * Set member parent
2355 *
2356 * @param integer $id Parent identifier
2357 *
2358 * @return $this
2359 */
2360 public function setParent(int $id): self
2361 {
2362 $this->parent = $id;
2363 $this->loadParent();
2364 return $this;
2365 }
2366
2367 /**
2368 * Get current due status
2369 *
2370 * @return int
2371 */
2372 public function getDueStatus(): int
2373 {
2374 return $this->due_status;
2375 }
2376
2377 /**
2378 * Get prefix for events
2379 *
2380 * @return string
2381 */
2382 protected function getEventsPrefix(): string
2383 {
2384 return 'member';
2385 }
2386 }