Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
TripAccess
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
8 / 8
20
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
 roleFor
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 findReadable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReadable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readableWithRole
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getForExpenseWrite
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getForManage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 accessibleTrips
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace App\Trip\Application;
6
7use App\Trip\Domain\Exceptions\TripAccessDenied;
8use App\Trip\Domain\Exceptions\TripNotFound;
9use App\Trip\Domain\Trip;
10use App\Trip\Domain\TripMemberRepository;
11use App\Trip\Domain\TripRepository;
12use App\Trip\Domain\ValueObject\MemberId;
13use App\Trip\Domain\ValueObject\OwnerId;
14use App\Trip\Domain\ValueObject\TripId;
15use App\Trip\Domain\ValueObject\TripRole;
16
17/**
18 * Resolves what a user may do with a trip (ReBAC): access = owner OR member.
19 * A user with no access sees the trip as absent (404, no existence leak); a
20 * member who lacks a capability is denied (403). This is the single place the
21 * ownership/role rule lives — the read/write/manage use cases go through it.
22 * Replaces the former owner-only OwnedTripFinder.
23 */
24final readonly class TripAccess
25{
26    public function __construct(
27        private TripRepository $trips,
28        private TripMemberRepository $members,
29    ) {
30    }
31
32    /** The user's role on the trip, or null if they have no access at all. */
33    public function roleFor(Trip $trip, string $userId): ?TripRole
34    {
35        if ($trip->isOwnedBy(new OwnerId($userId))) {
36            return TripRole::OWNER;
37        }
38
39        return $this->members->roleOf($trip->id(), new MemberId($userId));
40    }
41
42    public function findReadable(TripId $id, string $userId): ?Trip
43    {
44        return $this->readableWithRole($id, $userId)[0];
45    }
46
47    public function getReadable(TripId $id, string $userId): Trip
48    {
49        return $this->findReadable($id, $userId) ?? throw TripNotFound::withId($id);
50    }
51
52    /**
53     * @return array{?Trip, ?TripRole} the trip and the caller's role, both null
54     *                                 when the user has no access
55     */
56    public function readableWithRole(TripId $id, string $userId): array
57    {
58        $trip = $this->trips->ofId($id);
59        if ($trip === null) {
60            return [null, null];
61        }
62
63        $role = $this->roleFor($trip, $userId);
64
65        return $role === null ? [null, null] : [$trip, $role];
66    }
67
68    /** Owner or editor only; TripNotFound for a stranger, TripAccessDenied for a viewer. */
69    public function getForExpenseWrite(TripId $id, string $userId): Trip
70    {
71        [$trip, $role] = $this->readableWithRole($id, $userId);
72        if ($trip === null || $role === null) {
73            throw TripNotFound::withId($id);
74        }
75        if (!$role->canWriteExpense()) {
76            throw TripAccessDenied::cannotWriteExpense();
77        }
78
79        return $trip;
80    }
81
82    /** Owner only; TripNotFound for a stranger, TripAccessDenied for a non-owner member. */
83    public function getForManage(TripId $id, string $userId): Trip
84    {
85        [$trip, $role] = $this->readableWithRole($id, $userId);
86        if ($trip === null || $role === null) {
87            throw TripNotFound::withId($id);
88        }
89        if (!$role->canManageTrip()) {
90            throw TripAccessDenied::cannotManageTrip();
91        }
92
93        return $trip;
94    }
95
96    /**
97     * Every trip the user can see, each with their role: trips they own (OWNER)
98     * plus trips they're a member of (editor/viewer).
99     *
100     * ponytail: loads member trips one id at a time (member count per user is
101     * tiny). Upgrade path = TripRepository::ofIds batch if it ever bites.
102     *
103     * @return list<array{Trip, TripRole}>
104     */
105    public function accessibleTrips(string $userId): array
106    {
107        $result = [];
108
109        foreach ($this->trips->ofOwner(new OwnerId($userId)) as $trip) {
110            $result[] = [$trip, TripRole::OWNER];
111        }
112
113        foreach ($this->members->ofUser(new MemberId($userId)) as $member) {
114            $trip = $this->trips->ofId($member->tripId());
115            if ($trip !== null) {
116                $result[] = [$trip, $member->role()];
117            }
118        }
119
120        return $result;
121    }
122}