# Employee Permissions — Backend Changes Required (2026-08)

**Requested by:** frontend team
**Target:** `dev`
**Why:** the owner panel is being changed so employees only see the sections they were granted. The frontend can hide a page, but the gate has to exist here first — and while auditing we found one unguarded money path (item 2) that is worth fixing regardless of the frontend work.

Implement in the order below. Items 1–4 are small and independent of the dashboard work; items 5–7 are one piece.

---

## Background: three problems in the current setup

**A. A permission that exists but can never be granted.**
`RolesAndPermissionsSeeder` creates `discount_templates.view/create/update/delete`, and `DiscountTemplatePolicy` genuinely enforces them for employees. But `discount_templates` is **not** in `config/permissions.php` → `resources`, so:

- `PermissionCatalog::matrix()` never returns it → it never appears in the owner's permission grid,
- `PermissionCatalog::all()` never contains it → `UpdateEmployeePermissionsRequest` **rejects it as invalid** even if a client sent it.

Net effect: `$user->can('discount_templates.view')` is `false` for every employee, forever. **Every employee is permanently 403'd out of discount templates.** Item 1 fixes this.

**B. Endpoints employees can reach with no permission behind them.**
`owner/calendar`, `occasions`, and `transactions` are all under `role:owner|employee` but have no granular gate. The owner has no way to withhold them. Items 2–4.

**C. The dashboard is `role:owner` only.**
Employees currently 403 on `owner/dashboard`. Items 5–7 open it and filter its payload per permission.

The rule the frontend is now built around, which is worth keeping as a project invariant:

> **The permission catalog is exactly the set of permissions the backend enforces — nothing more, nothing less.**

Problem A is a violation in one direction (enforced, not in catalog). `customers.*` is a mild violation in the other (in the catalog, enforced by `CustomerPolicy`, but the web app has no customers page yet). **Leave `customers` alone** — removing it from the catalog while `CustomerPolicy` still calls `$user->can('customers.view')` would recreate problem A exactly.

---

## 1. Per-resource actions in the permission catalog

Today the catalog is a rigid *resources × 4 actions* grid. Adding `dashboard`, `calendar`, `occasions` and `transactions` to that grid would produce meaningless permissions (`dashboard.delete`, `revenue.create`, `calendar.update`) and 48 switches in the owner's UI, a third of which do nothing.

Instead, let each resource declare its own actions, defaulting to all four.

### 1a. `config/permissions.php`

`resources` changes shape from `key => string` to `key => array`:

```php
'resources' => [
    'buildings'          => ['label' => 'Buildings'],
    'units'              => ['label' => 'Units'],
    'reservations'       => ['label' => 'Reservations'],
    'customers'          => ['label' => 'Customers',     'actions' => ['view', 'create', 'update']],
    'employees'          => ['label' => 'Employees'],
    'promo_codes'        => ['label' => 'Promo Codes'],
    'discount_templates' => ['label' => 'Discount Templates'],
    'revenue'            => ['label' => 'Revenue',       'actions' => ['view']],
    'transactions'       => ['label' => 'Transactions',  'actions' => ['view', 'create']],
    'calendar'           => ['label' => 'Calendar',      'actions' => ['view']],
    'occasions'          => ['label' => 'Occasions',     'actions' => ['view']],
    'dashboard'          => ['label' => 'Dashboard',     'actions' => ['view']],
],
```

Each action list was chosen to match the policy that enforces it — please keep them in sync rather than adding actions speculatively:

| Resource | Actions | Why not four |
|---|---|---|
| `customers` | view, create, update | `CustomerPolicy::delete` is admin-only |
| `transactions` | view, create | `TransactionPolicy` has no `update`/`delete` |
| `revenue` | view | read-only endpoints |
| `calendar` | view | `owner/calendar` is GET; block/unblock stay `role:owner` |
| `occasions` | view | create/update/delete are admin-only; `request` is `isApprovedOwner()` |
| `dashboard` | view | read-only aggregate |

Result: **12 resources, 33 permissions** (up from 7 / 28). A flat grid would have been 48.

### 1b. `app/Support/PermissionCatalog.php`

```php
public static function actionsFor(string $resource): array
{
    return config("permissions.resources.{$resource}.actions")
        ?? config('permissions.actions');
}

public static function all(): array
{
    $names = [];
    foreach (array_keys(config('permissions.resources')) as $resource) {
        foreach (self::actionsFor($resource) as $action) {
            $names[] = "{$resource}.{$action}";
        }
    }

    return $names;
}

public static function matrix(): array
{
    return collect(config('permissions.resources'))
        ->map(fn ($config, $key) => [
            'key' => $key,
            'label' => $config['label'],
            'permissions' => collect(self::actionsFor($key))
                ->mapWithKeys(fn ($a) => [$a => "{$key}.{$a}"])
                ->all(),
        ])
        ->values()
        ->all();
}
```

This one change fixes the catalog, the seeder and `UpdateEmployeePermissionsRequest` validation together, since all three read `PermissionCatalog`.

### 1c. Seeder

Re-run `RolesAndPermissionsSeeder`. `PermissionCatalog::all()` + `firstOrCreate` makes the 16 new rows idempotent. Also add the new resources' permissions to the `owner` role's `syncPermissions(...)` list. That is cosmetic — policies short-circuit owners before reaching `can()` — but it keeps the seeder honest, and the existing list already includes `discount_templates.*` and `revenue.view`.

Please also delete the now-redundant hand-written `$discountTemplatePermissions` block in the seeder; item 1a puts those names into `PermissionCatalog::all()`.

### Response shape change (frontend is expecting this)

`GET /owner/permission-catalog` — `resources[].permissions` becomes **sparse**. Keep the top-level `actions` key as the full four so the grid can still render column headers.

```jsonc
{
  "data": {
    "actions": ["view", "create", "update", "delete"],
    "resources": [
      {
        "key": "buildings",
        "label": "Buildings",
        "permissions": {
          "view": "buildings.view", "create": "buildings.create",
          "update": "buildings.update", "delete": "buildings.delete"
        }
      },
      {
        "key": "dashboard",
        "label": "Dashboard",
        "permissions": { "view": "dashboard.view" }   // ← only the declared actions
      }
    ]
  }
}
```

---

## 2. `TransactionPolicy` — currently unguarded (please prioritise)

**`TransactionPolicy` checks ownership only and never calls `can()`.** Any active employee can list transactions and record payments and refunds against any of their owner's reservations, regardless of what the owner granted them. `viewAny` is simply `isActiveOwnerOrEmployee($user) || $user->isCustomer()`.

Add the employee branch, following the pattern already used in `DiscountTemplatePolicy` and `ReservationPolicy` — permission is an **additional** gate, the existing ownership scoping stays:

```php
public function viewAny(User $user): bool
{
    if ($user->isCustomer()) {
        return true;
    }

    if (! $this->isActiveOwnerOrEmployee($user)) {
        return false;
    }

    if ($user->isActiveEmployee()) {
        return $user->can('transactions.view');
    }

    return true;
}
```

Same shape for `view` (`transactions.view`) and `create` (`transactions.create`), keeping the existing `$reservation` / `$ownerId` scoping in each. Do not weaken the customer branch.

This depends on item 1 to make `transactions.*` grantable. If you want to close the hole before the rest ships, an interim `$user->can('reservations.update')` check would narrow it, but the proper fix is the pair.

---

## 3. `OccasionPolicy` — add the employee gate

`viewAny` and `view` are currently `isAdmin() || isApprovedOwner() || isActiveEmployee()`. Add the employee permission check to both:

```php
if ($user->isActiveEmployee()) {
    return $user->can('occasions.view');
}
```

Leave `create`, `update`, `delete` and `approve` admin-only, and `request` on `isApprovedOwner()` — unchanged.

---

## 4. `CalendarController` — no authorization at all

`GET owner/calendar` is reachable by any owner or employee and the controller has **no `authorize()` call**. Add one gating on `calendar.view` for employees, with owners falling through, consistent with the other policies.

`unit-availabilities/block` and `unblock` stay in the `role:owner` group — no change.

---

## 5. Open the dashboard to employees

The owner panel's dashboard should be grantable per employee.

- **`routes/api.php`** — move `owner/dashboard` out of the `role:owner` group (line ~140) into a `role:owner|employee` group.
- **`OwnerPolicy::dashboard()`** — currently `return $user->isApprovedOwner();`. Add:

  ```php
  if ($user->isActiveEmployee()) {
      return $user->can('dashboard.view');
  }
  ```

- **`OwnerDashboardController::index()`** — `$owner = $user->owner` is `null` for an employee, so the method returns `403 Owner profile not found.` before any widget logic runs. Resolve as:

  ```php
  $owner = $user->owner ?? $user->employee?->owner;
  ```

---

## 6. Scope dashboard metrics to the employee's buildings

An employee assigned to one building must not see portfolio-wide totals. This matches how `CustomerPolicy` and the reservation/unit list endpoints already scope employees via `employee.building_ids`.

`DashboardMetrics::ownerSummary()`, `ownerRevenueSeries()` and `ownerOccupancySeries()` all take an `Owner` and derive IDs internally through `buildingIdsFor($owner)`. Change all three to take an explicit `Collection $buildingIds`, and have the controller decide:

```php
$buildingIds = $user->isActiveEmployee()
    ? $user->employee->buildings()->pluck('buildings.id')
    : $owner->buildings()->pluck('id');
```

`RevenueService::summary($buildingIds, ...)` already takes building IDs, so it needs no change — only a different argument.

This is the largest single piece of work in this document.

**Edge case:** an employee with zero assigned buildings should get zeroed metrics, not an error or the owner's totals.

---

## 7. Filter the dashboard payload per permission, and split the chart series

Build `summary` and `charts` conditionally, omitting keys the viewer cannot see. Owners get everything.

| Payload key | Required permission |
|---|---|
| `summary.buildings` | `buildings.view` |
| `summary.units` | `units.view` |
| `summary.employees` | `employees.view` |
| `summary.active_reservations` | `reservations.view` |
| `summary.occupancy_rate` | `units.view` **and** `reservations.view` |
| `summary.collected` / `refunds` / `outstanding` / `currency_code` | `revenue.view` |
| `charts.revenue` | `revenue.view` |
| `charts.bookings` | `reservations.view` |
| `charts.occupancy` | `units.view` **and** `reservations.view` |

### Split `charts.revenue` into two series (BREAKING — coordinate the deploy)

`ownerRevenueSeries()` returns points shaped `{date, revenue, bookings}`, and the frontend derives two separate charts from that one array. An employee with `reservations.view` but **not** `revenue.view` needs the bookings numbers without the money, which cannot be done cleanly while the two are interleaved in one payload.

```jsonc
// before
"charts": { "revenue": [{ "date": "2026-08", "revenue": 5200, "bookings": 14 }], "occupancy": [...] }

// after
"charts": {
  "revenue":   [{ "date": "2026-08", "revenue": 5200 }],   // omitted without revenue.view
  "bookings":  [{ "date": "2026-08", "bookings": 14 }],    // omitted without reservations.view
  "occupancy": [...]                                        // omitted without units+reservations view
}
```

The frontend already splits these client-side, so this simplifies both sides — but it **is** a breaking response-shape change. It needs to deploy together with the frontend change; please flag it when the branch is ready.

### While you are in there

`OwnerDashboardController` runs a full `RevenueService::summary()` on every request to populate `collected`, `refunds` and `outstanding` — and the owner dashboard page currently renders **none** of those three fields. At minimum, skip the call entirely when the viewer lacks `revenue.view`. Whether to keep computing it for owners is your call; the frontend is not using it today.

---

## Testing

- **Policies:** for transactions, occasions, calendar and dashboard — an employee **with** the permission passes, **without** it gets 403, and a cross-owner request is still denied. The last one matters: the permission is an additional gate, not a replacement for the ownership scoping.
- **Catalog:** `PermissionCatalog::all()` returns 33 names; `matrix()` returns sparse `permissions` maps for the view-only resources; `UpdateEmployeePermissionsRequest` now accepts `discount_templates.view` (it rejects it today) and still rejects `dashboard.delete`.
- **Dashboard scoping:** an employee assigned to one of an owner's three buildings sees counts for that building only, not the owner's totals. An employee with zero buildings gets zeroes.
- **Dashboard filtering:** an employee with `dashboard.view` + `units.view` only receives `summary.units` and nothing else — in particular no `charts.revenue` and no `occupancy_rate`.
- **Regression:** owners see exactly what they see today (policies fall through), and customers are unaffected by the `TransactionPolicy` change.

---

## Rollout warning

Permissions default to **absent**, so on deploy every existing employee immediately loses:

- calendar, occasions, transactions, discount-templates (which they never really had — see problem A),
- **and the dashboard**, which they cannot reach today either (it is `role:owner`), but which will now appear grantable and stay dark until an owner turns it on.

That is the intended fail-closed behavior, but owners will notice. Two suggestions:

1. Consider a one-off migration granting `dashboard.view` and `calendar.view` to all existing active employees, so only the genuinely new gates (`transactions`, `occasions`) bite. Your call — it trades a cleaner rollout for a less strict default.
2. Whatever you choose, the owners need a heads-up at release, since they are the ones who have to re-grant.

---

## What the frontend does once this lands

For context, so the contract is clear from both ends:

- Route access moves onto each page as `definePageMeta({ access: … })`, typed as `"owner" | "employee" | <permission>`, and **fails closed** — a page with no declaration is invisible to employees.
- The sidebar is filtered by the same function the route guard uses, so employees never see a link they cannot open.
- Dashboard widgets are hidden per permission, mirroring the table in item 7. Server-side omission is the real boundary; the client-side hiding is presentation.
- Pages that stay `role:owner` here (`owner/dashboard` excepted), namely invoices, about-to-end and permission-templates, are marked owner-only on the frontend and will stop appearing for employees.

Full frontend design: `docs/superpowers/specs/2026-08-30-employee-view-permissions-design.md` in `turista-buildings-web`.
