Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
OwnershipTransferOnUserDeletion
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
3 / 3
7
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onUserDeletion
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 pickHeir
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Trip\Application;
6
7use App\Identity\Application\Port\PreUserDeletion;
8use App\Shared\Infrastructure\Storage\ImageStorage;
9use App\Trip\Domain\TripMember;
10use App\Trip\Domain\TripMemberRepository;
11use App\Trip\Domain\TripRepository;
12use App\Trip\Domain\ValueObject\OwnerId;
13use App\Trip\Domain\ValueObject\TripRole;
14
15/**
16 * Before a user is deleted, hand off the trips they own that still have members
17 * to a new owner (editor preferred over viewer, oldest membership wins). The new
18 * owner's membership row is removed — they become the owner column, not a member
19 * row. Owned trips with no members are left untouched: the user delete cascade
20 * removes them (and their expenses) as before.
21 */
22final readonly class OwnershipTransferOnUserDeletion implements PreUserDeletion
23{
24    public function __construct(
25        private TripRepository $trips,
26        private TripMemberRepository $members,
27        private ImageStorage $storage,
28    ) {
29    }
30
31    public function onUserDeletion(string $userId): void
32    {
33        foreach ($this->trips->ofOwner(new OwnerId($userId)) as $trip) {
34            $heir = $this->pickHeir($this->members->ofTrip($trip->id()));
35            if ($heir === null) {
36                // Memberless: the row is cascade-deleted with the user, so this is
37                // the last chance to take its banner file with it.
38                $this->storage->delete($trip->imageName());
39
40                continue;
41            }
42
43            $trip->transferOwnershipTo(new OwnerId($heir->userId()->value()));
44            $this->trips->save($trip);
45            $this->members->remove($heir);
46        }
47    }
48
49    /**
50     * Editor preferred over viewer, then oldest membership. ofTrip() already
51     * orders createdAt ASC, so the first editor (else the first member) is it.
52     *
53     * @param list<TripMember> $members
54     */
55    private function pickHeir(array $members): ?TripMember
56    {
57        foreach ($members as $member) {
58            if ($member->role() === TripRole::EDITOR) {
59                return $member;
60            }
61        }
62
63        return $members[0] ?? null;
64    }
65}