# Team module

Staffing & store access — the Port 00 foundation every ported BuyerKiosk module
builds on. Full design: `docs/porting/port-00-employees-store-access.md` (repo
root). This README tracks what is actually built.

## Rules (v1)

- **Employment is a `StoreMembership` row** (unique per `(store_id, user_id)`,
  UUID PK, no soft deletes). `status` is THE lifecycle: `active` / `terminated`.
  Rehire flips the existing row back to active (owner-only — it resurrects an
  owner-set pay rate); pay-rate history is preserved.
- **Capabilities are the check primitive; roles are presets.** Code asks
  `StoreMembership::can(StoreCapability::X)`, never "is this a manager?".
  The v1 preset matrix lives in `StoreRole::capabilities()` and is pinned by a
  snapshot test (`tests/Unit/Team/StoreRoleTest`). `rank()` (owner 40 >
  manager 30 > shift_lead 20 > sales 10) is for people-management targeting
  rules only, never feature gating.
- **Exactly one owner membership per store**, in lockstep with
  `stores.owner_user_id`. Service-enforced (`MembershipProvisioner`), DB
  backstop (partial unique index `store_memberships_one_owner_per_store`).
  The owner membership cannot be terminated, demoted, or re-roled.
- **Position ≠ role**: `StorePosition` is a schedulable label (Owner/Manager/
  Shift Lead/Sales seeded per store; stores add custom ones) and never touches
  access control.
- **Pay** = `MembershipPayRate`, append-only integer cents; resolution is
  latest `effective_at` at or before the instant
  (`StoreMembership::payRateAt()`).
- Platform roles (spatie `buyer`/`seller`/`admin`) stay platform-level;
  employment never grants `seller`.

## Built so far (slices 1–4 of Port 00 §9)

### Slice 4 — invitations + email

- `POST /stores/{store}/invitations` (`store.can:team.manage`) — invite by
  email. `InvitationService` normalizes the email, enforces role-below-inviter
  (owner never invitable), auto-revokes any prior pending invite for the pair
  inside the create txn (so the partial unique backstop never deadlocks
  re-invites), generates a 32-byte token stored only as sha256, binds
  `invited_user_id` when the email already has an account, and emails a
  one-time accept link. The raw token never touches the DB or any API response.
- `GET /stores/{store}/invitations` (team.manage) — pending only.
- `DELETE .../{invitation}` (team.manage) — revoke; nested-scoped.
- `POST /v1/invitations/accept` (auth + `throttle:invitation-accept`) — token
  in the **body**, never the path. Bound invites accept only from the bound
  account; unbound require an email match; an existing membership (active or
  terminated) blocks accept (terminated → owner-only rehire). Creates the
  active membership + marks the invite accepted in one txn.
- `StoreInvitationNotification` (Notifications module) — mail-only on-demand
  notification (recipient may not be a user yet), markdown template
  `emails/team/invitation.blade.php`. `TeamServiceProvider` defines the
  `invitation-accept` rate limiter.

### Slice 3 — the Team module API

Endpoints (all under `/v1`, gated per the §6 matrix):
- `GET /me/memberships` (auth) — the caller's active memberships + resolved
  capability list; the single source the frontend gates from.
- `GET /stores/{store}/members` (`store.can:team.view`) — roster;
  `StoreMemberResource` strips fields by the *viewer's* capabilities server-side
  (contact needs team.manage, pay needs pay.view — never client-side).
- `PATCH /stores/{store}/members/{membership}` (`store.can:team.manage`) —
  role / default position / rehire, enforced by `MembershipManager`: target
  ranks strictly below actor, no promote-to/above-self, owner immutable,
  rehire owner-only.
- `DELETE .../{membership}` (`store.can:team.manage`) — terminate; emits
  `MembershipTerminated` inside the txn (registered in EventServiceProvider,
  no listeners until port-01).
- `GET/POST .../{membership}/pay-rates` (`store.can:pay.view` / `pay.manage`) —
  append-only cents history.
- `GET /stores/{store}/positions` (`store.member`),
  `POST`/`PATCH .../positions` (`store.can:schedule.manage`).

Every nested param (`{membership}`, `{position}`) and body id
(`default_position_id`) is asserted to belong to `{store}` or 404s
(cross-store IDOR). Writes log via `activity('team')`. Contract hand-added to
`contracts/openapi.yaml` under the `Team` tag.

### Slice 2 — the authorization seam

- `StoreViewerResolver` (+ `StoreViewer` in `Data/`) — THE single seam: every
  "what may this user do at this store" answer resolves here. Platform admin
  → synthetic owner (all capabilities, no membership); active membership →
  role-preset capabilities; otherwise null (fail closed).
- Middleware (`app/Http/Middleware/`): `EnsureStoreCapability` (aliases
  `store.can:{capability}` and legacy `store.owner`; bare alias falls back to
  `store.admin` — fail closed) and `EnsureStoreMembership` (`store.member`,
  real active membership only — admins without one get 403). The resolved
  `StoreViewer` rides request attributes. `EnsureStoreOwner` is deleted.
- All raw `stores.owner_user_id` / `users.store_id` ownership checks rewired
  through the seam: Returns (SellerReturnController, ReturnTransitioner,
  ProactiveReturnService, ReturnAccess), Messaging (MessageThreadAccess,
  MyThreadsController), SellerReviewsController,
  StoreController::showPublic (private-shape leak closed),
  StorefrontController::canPreview. Query-shape sites use
  `StoreMembership::scopeGranting()` / `StoreRole::granting()`.
- Parity + seam-unification + middleware test suites
  (`tests/Feature/Team/StoreAccessParityTest`, `SeamUnificationTest`,
  `tests/Feature/Middleware/`).

### Slice 1 — foundations

- Enums `StoreRole`, `StoreCapability`, `MembershipStatus`
  (`app/Support/Enums/`).
- Migrations + shared models (`app/Models/`): `StoreMembership`,
  `MembershipPayRate`, `StorePosition`, `StoreInvitation` (schema only — the
  invite lifecycle service/endpoints are slice 4).
- Factories, incl. the new feature-test idiom
  `StoreMembership::factory()->owner()/->manager()/->shiftLead()/->sales()`.
- `MembershipProvisioner` — owner membership + default positions, wired into
  store creation (same transaction as `owner_user_id`).
- `php artisan team:audit-store-access` — pre-backfill report of
  `users.store_id` contamination (Port 00 §5 step 1). Report-only.
- `php artisan team:backfill-owner-memberships` — idempotent owner-membership
  + positions backfill over `Store::withTrashed()`; skips ghost owners with a
  report line.

## Not yet built (later slices)

- Slices 5–8: alqove-web frontend (contract sync, team endpoints/hooks,
  `/seller/team`), seller store-context migration, `(staff)` shell +
  invitation accept page, user-scoped route migration.

## Deferred by design (Port 00 §10)

Ownership transfer; custom roles; membership-aware notification routing;
platform-wide email verification; token expiry.
