# Service Provider Portal Web Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the complete authenticated provider-owner portal, including account flows, server-side JWT session, provider bridge, profile, locations, operations, media, verifications, and review submission.

**Architecture:** Laravel acts as a BFF: controllers remain thin, Application owns session/use-case orchestration, Domain owns route/state registries, and Infrastructure calls `api_mycode`. Blade renders a shell; focused JavaScript modules operate through explicit bridge routes and never receive a JWT.

**Tech Stack:** PHP 8.3+, Laravel 13, Blade, Laravel HTTP client, vanilla JavaScript, Laravel Mix, PHPUnit 12, Google Maps JavaScript API.

---

### Task 1: Provider session primitives

**Files:**
- Create: `app/Application/Provider/ProviderJwtValidator.php`
- Create: `app/Application/Provider/ProviderSessionManager.php`
- Create: `tests/Unit/Application/Provider/ProviderJwtValidatorTest.php`
- Create: `tests/Feature/Concerns/FakesProviderSession.php`

- [ ] **Step 1: Write failing validator tests**

```php
public function test_it_rejects_expired_tokens(): void
{
    Carbon::setTestNow('2026-07-27 12:00:00');
    $token = $this->jwt(['sub' => '7', 'exp' => now()->subMinute()->timestamp]);

    self::assertFalse((new ProviderJwtValidator())->validate($token));
}

public function test_it_accepts_unexpired_three_part_tokens(): void
{
    $token = $this->jwt(['sub' => '7', 'exp' => now()->addHour()->timestamp]);

    self::assertTrue((new ProviderJwtValidator())->validate($token));
}
```

- [ ] **Step 2: Run the tests and confirm the red state**

Run: `php artisan test tests/Unit/Application/Provider/ProviderJwtValidatorTest.php`

Expected: FAIL because `ProviderJwtValidator` does not exist.

- [ ] **Step 3: Implement token validation and isolated session keys**

```php
final class ProviderJwtValidator
{
    public function validate(string $token): bool
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            return false;
        }

        $payload = json_decode((string) base64_decode(
            strtr($parts[1], '-_', '+/'),
            true
        ), true);

        return is_array($payload)
            && (! isset($payload['exp']) || time() < (int) $payload['exp']);
    }
}
```

`ProviderSessionManager` must use only these keys:

```php
private const TOKEN_KEY = 'provider_api_token';
private const USER_KEY = 'provider_user';
private const TWO_FACTOR_TOKEN_KEY = 'provider_temporary_2fa_token';
```

It exposes `storeAuthenticated()`, `storePendingTwoFactor()`, `token()`,
`user()`, `pendingTwoFactorToken()`, `isAuthenticated()`, `forgetPending()`,
and `forget()`. `storeAuthenticated()` validates the JWT and removes password,
password confirmation, recovery codes, and 2FA secrets from the user array.

The test concern returns a valid provider JWT and session state:

```php
protected function validProviderJwt(): string
{
    $header = rtrim(strtr(base64_encode('{"alg":"HS256","typ":"JWT"}'), '+/', '-_'), '=');
    $payload = rtrim(strtr(base64_encode(json_encode([
        'sub' => '7',
        'exp' => time() + 3600,
        'role' => 'user',
    ], JSON_THROW_ON_ERROR)), '+/', '-_'), '=');

    return $header . '.' . $payload . '.signature';
}

protected function withProviderSession(): static
{
    return $this->withSession([
        'provider_api_token' => $this->validProviderJwt(),
        'provider_user' => ['id' => 7, 'role' => 'user'],
    ]);
}
```

- [ ] **Step 4: Run focused tests**

Run: `php artisan test tests/Unit/Application/Provider/ProviderJwtValidatorTest.php`

Expected: PASS.

- [ ] **Step 5: Commit while keeping version 1.1.3**

```bash
git add app/Application/Provider tests/Unit/Application/Provider tests/Feature/Concerns/FakesProviderSession.php
git commit -m "feat: add provider web session primitives"
```

At the version hook, choose **Mantener**.

### Task 2: Login, 2FA, registration, verification, and password recovery

**Files:**
- Create: `app/Application/Provider/ProviderAuthException.php`
- Create: `app/Application/Provider/ProviderAuthGateway.php`
- Create: `app/Infrastructure/Http/ProviderAuthApi.php`
- Create: `app/Presentation/Http/Controllers/Web/ProviderSessionController.php`
- Create: `app/Presentation/Http/Controllers/Web/ProviderAccountController.php`
- Create: `app/Http/Middleware/EnsureProviderWebSession.php`
- Modify: `app/Http/Kernel.php`
- Modify: `app/Providers/AppServiceProvider.php`
- Modify: `routes/web.php`
- Create: `tests/Feature/ProviderSessionTest.php`
- Create: `tests/Feature/ProviderAccountFlowsTest.php`

- [ ] **Step 1: Write failing session and account feature tests**

```php
public function test_login_keeps_api_token_out_of_response_and_in_session(): void
{
    Http::fake(['https://api.mycode.cl/auth/login' => Http::response([
        'access_token' => $this->validProviderJwt(),
        'user' => ['id' => 7, 'email' => 'owner@example.com', 'role' => 'user'],
    ])]);

    $this->postJson('/mi-prestador/session/login', [
        'email' => 'owner@example.com',
        'password' => 'secret123',
    ])->assertOk()
      ->assertJsonMissing(['access_token' => $this->validProviderJwt()])
      ->assertSessionHas('provider_api_token');
}

public function test_two_factor_challenge_is_completed_server_side(): void
{
    Http::fakeSequence()
        ->push(['requires_2fa' => true, 'temporary_2fa_token' => 'temporary', 'expires_in' => 300])
        ->push(['access_token' => $this->validProviderJwt(), 'user' => ['id' => 7, 'role' => 'user']]);

    $this->postJson('/mi-prestador/session/login', [
        'email' => 'owner@example.com',
        'password' => 'secret123',
    ])->assertJsonPath('requires_2fa', true);

    $this->postJson('/mi-prestador/session/2fa', ['code' => '123456'])
        ->assertOk()
        ->assertJsonPath('authenticated', true);
}
```

Add tests for recovery-code verification, registration, email verification,
email resend, forgot password, reset password, status, logout, invalid
credentials, 422 propagation, and unauthenticated redirect.

- [ ] **Step 2: Run the focused feature tests**

Run: `php artisan test tests/Feature/ProviderSessionTest.php tests/Feature/ProviderAccountFlowsTest.php`

Expected: FAIL because routes and handlers are absent.

- [ ] **Step 3: Implement the auth gateway and adapter**

```php
interface ProviderAuthGateway
{
    public function login(array $credentials): array;
    public function verifyTwoFactor(string $temporaryToken, string $code): array;
    public function verifyRecoveryCode(string $temporaryToken, string $code): array;
    public function register(array $payload): array;
    public function verifyEmail(array $payload): array;
    public function resendEmail(array $payload): array;
    public function forgotPassword(array $payload): array;
    public function resetPassword(array $payload): array;
}
```

`ProviderAuthApi` maps those operations to `/auth/login`,
`/auth/2fa/verify`, `/auth/2fa/recovery-code/verify`, `/auth/register`,
`/auth/email/verify`, `/auth/email/resend`, `/auth/password/forgot`, and
`/auth/password/reset` through `ApiMyCodeHttpClient`.

- [ ] **Step 4: Implement thin controllers and middleware**

Controller login logic:

```php
$result = $this->auth->login($request->validate([
    'email' => ['required', 'email', 'max:255'],
    'password' => ['required', 'string', 'min:6'],
    'remember_me' => ['sometimes', 'boolean'],
]));

if (($result['requires_2fa'] ?? false) === true) {
    $this->session->storePendingTwoFactor((string) $result['temporary_2fa_token']);
    return response()->json(['requires_2fa' => true]);
}

$request->session()->regenerate();
$this->session->storeAuthenticated(
    (string) $result['access_token'],
    is_array($result['user'] ?? null) ? $result['user'] : [],
);

return response()->json(['authenticated' => true, 'user' => $this->session->user()]);
```

Register middleware aliases `provider.web` and later `provider.bridge`.
Private page requests redirect to `/mi-prestador/login`; JSON requests return
401.

- [ ] **Step 5: Bind the gateway and register routes**

```php
$this->app->bind(
    \App\Application\Provider\ProviderAuthGateway::class,
    \App\Infrastructure\Http\ProviderAuthApi::class,
);
```

All session/account routes live under `Route::prefix('mi-prestador')`.
Only dashboard/profile/location/review routes receive `provider.web`.

- [ ] **Step 6: Run the tests**

Run: `php artisan test tests/Feature/ProviderSessionTest.php tests/Feature/ProviderAccountFlowsTest.php`

Expected: PASS with no JWT in any JSON response.

- [ ] **Step 7: Commit**

```bash
git add app/Application/Provider app/Infrastructure/Http/ProviderAuthApi.php app/Presentation/Http/Controllers/Web app/Http/Kernel.php app/Http/Middleware/EnsureProviderWebSession.php app/Providers/AppServiceProvider.php routes/web.php tests/Feature
git commit -m "feat: add provider account and session flows"
```

At the hook, choose **Mantener**.

### Task 3: Provider bridge route registry and session protection

**Files:**
- Create: `app/Domain/ServiceProvider/ProviderBridgeRoute.php`
- Create: `app/Application/Bridge/ProviderBridgeRouteRegistry.php`
- Create: `app/Http/Middleware/EnsureProviderBridgeSession.php`
- Create: `app/Presentation/Http/Controllers/Bridge/ProviderBridgeController.php`
- Modify: `app/Http/Kernel.php`
- Modify: `routes/api.php`
- Create: `tests/Unit/Application/Bridge/ProviderBridgeRouteRegistryTest.php`
- Create: `tests/Feature/ProviderBridgeSecurityTest.php`

- [ ] **Step 1: Write failing allowlist and security tests**

```php
public function test_registry_rejects_arbitrary_paths(): void
{
    $registry = new ProviderBridgeRouteRegistry();
    $providerId = '11111111-1111-4111-8111-111111111111';

    self::assertNull($registry->resolve('GET', 'admin/users'));
    self::assertNull($registry->resolve('POST', "service-providers/{$providerId}/approve"));
    self::assertNotNull($registry->resolve('GET', 'service-providers/mine'));
}

public function test_bridge_requires_server_session(): void
{
    $this->getJson('/api/bridge/provider/service-providers/mine')
        ->assertUnauthorized();

    Http::assertNothingSent();
}
```

- [ ] **Step 2: Run focused tests**

Run: `php artisan test tests/Unit/Application/Bridge/ProviderBridgeRouteRegistryTest.php tests/Feature/ProviderBridgeSecurityTest.php`

Expected: FAIL because the registry and route do not exist.

- [ ] **Step 3: Implement a declarative allowlist**

`ProviderBridgeRoute` contains method, regex, and endpoint template. The
registry accepts only:

```php
private const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}';

private const ROUTES = [
    ['POST', '#^service-providers$#'],
    ['GET', '#^service-providers/mine$#'],
    ['GET|PUT|DELETE', '#^service-providers/' . self::UUID . '$#'],
    ['POST', '#^service-providers/' . self::UUID . '/submit-review$#'],
    ['PUT', '#^service-providers/' . self::UUID . '/categories$#'],
    ['GET|POST', '#^service-providers/' . self::UUID . '/locations$#'],
    ['GET|PUT|DELETE', '#^service-locations/' . self::UUID . '$#'],
    ['PUT', '#^service-locations/' . self::UUID . '/(services|specialties|features|schedules|schedule-exceptions)$#'],
    ['POST', '#^service-locations/' . self::UUID . '/contacts$#'],
    ['PUT|DELETE', '#^service-location-contacts/' . self::UUID . '$#'],
    ['POST', '#^service-providers/' . self::UUID . '/(media|verifications)$#'],
    ['POST', '#^service-locations/' . self::UUID . '/images$#'],
    ['DELETE', '#^service-location-images/' . self::UUID . '$#'],
    ['DELETE', '#^service-provider-verifications/' . self::UUID . '$#'],
    ['GET', '#^public/service-catalogs/(categories|services|specialties|features)$#'],
];
```

Expand `GET|PUT` strings into exact method sets before matching. Every resolved
route maps to the upstream prefix `/api/v1/`; browser bridge URLs do not
contain that upstream prefix.

- [ ] **Step 4: Add middleware and controller**

`EnsureProviderBridgeSession` retrieves the token only from
`ProviderSessionManager`, writes `Authorization: Bearer <token>` to the
internal request, and returns 401 when absent.

`ProviderBridgeController` resolves the route, rejects unknown paths with 404,
delegates payload construction, and never concatenates an unvalidated path.

- [ ] **Step 5: Register the bridge**

```php
Route::match(['get', 'post', 'put', 'delete'], 'bridge/provider/{path}', [ProviderBridgeController::class, 'proxy'])
    ->where('path', '.*')
    ->middleware(['web', 'provider.bridge'])
    ->name('bridge.provider.proxy');
```

- [ ] **Step 6: Verify red-to-green**

Run: `php artisan test tests/Unit/Application/Bridge/ProviderBridgeRouteRegistryTest.php tests/Feature/ProviderBridgeSecurityTest.php`

Expected: PASS, including rejection of traversal strings and admin routes.

- [ ] **Step 7: Commit**

```bash
git add app/Domain/ServiceProvider app/Application/Bridge/ProviderBridgeRouteRegistry.php app/Http/Middleware/EnsureProviderBridgeSession.php app/Presentation/Http/Controllers/Bridge/ProviderBridgeController.php app/Http/Kernel.php routes/api.php tests
git commit -m "feat: secure provider api bridge"
```

Choose **Mantener** in the version hook.

### Task 4: JSON validation and upstream error normalization

**Files:**
- Create: `app/Application/Bridge/ProviderProxyPayloadResolver.php`
- Create: `app/Application/Bridge/BridgeUpstreamResponseFactory.php`
- Create: `app/Application/Bridge/ProviderBridgeProxy.php`
- Modify: `app/Presentation/Http/Controllers/Bridge/ProviderBridgeController.php`
- Create: `tests/Unit/Application/Bridge/ProviderProxyPayloadResolverTest.php`
- Create: `tests/Feature/ProviderBridgeOperationsTest.php`

- [ ] **Step 1: Write failing payload tests**

```php
public function test_location_payload_requires_coordinates_and_address(): void
{
    $request = Request::create('/', 'POST', ['name' => 'Casa matriz']);

    $this->expectException(ValidationException::class);

    (new ProviderProxyPayloadResolver())->resolve(
        'POST',
        'service-providers/11111111-1111-4111-8111-111111111111/locations',
        $request,
    );
}
```

Add successful fixtures for provider, categories, location, services,
specialties, features, schedules, exceptions, contacts, and submit-review.

- [ ] **Step 2: Run the test and confirm failure**

Run: `php artisan test tests/Unit/Application/Bridge/ProviderProxyPayloadResolverTest.php`

Expected: FAIL because the resolver is absent.

- [ ] **Step 3: Implement validation maps**

Use private rule methods selected from resolved route names. Example location
rules:

```php
return $request->validate([
    'name' => ['required', 'string', 'max:150'],
    'description' => ['nullable', 'string', 'max:5000'],
    'address_line' => ['required', 'string', 'max:255'],
    'formatted_address' => ['required', 'string', 'max:500'],
    'commune' => ['required', 'string', 'max:120'],
    'region' => ['required', 'string', 'max:120'],
    'country_code' => ['required', 'string', 'size:2'],
    'latitude' => ['required', 'numeric', 'between:-90,90'],
    'longitude' => ['required', 'numeric', 'between:-180,180'],
    'location_source' => ['required', Rule::in([
        'geocoded',
        'map_pin',
        'browser_geolocation',
        'manual_coordinates',
    ])],
    'timezone' => ['nullable', 'string', 'max:80'],
    'is_24_hours' => ['sometimes', 'boolean'],
    'has_emergency_service' => ['sometimes', 'boolean'],
    'provides_home_service' => ['sometimes', 'boolean'],
    'home_service_radius_km' => ['nullable', 'numeric', Rule::in([5, 10, 25, 50, 100, 200])],
]);
```

DELETE resolves to an empty payload. Unknown combinations throw a domain
exception and become 404.

- [ ] **Step 4: Normalize upstream responses**

`BridgeUpstreamResponseFactory` returns JSON or allowed binary content, copies
only `Content-Type`, `Cache-Control`, `Content-Disposition`, and
`Retry-After`, and maps a non-JSON body to a neutral 502. It must never include
exception messages in production responses.

- [ ] **Step 5: Run bridge operation tests**

Run: `php artisan test tests/Unit/Application/Bridge/ProviderProxyPayloadResolverTest.php tests/Feature/ProviderBridgeOperationsTest.php`

Expected: PASS for all JSON operations and error statuses.

- [ ] **Step 6: Commit**

```bash
git add app/Application/Bridge app/Presentation/Http/Controllers/Bridge/ProviderBridgeController.php tests
git commit -m "feat: validate provider bridge operations"
```

Choose **Mantener**.

### Task 5: Streamed multipart uploads

**Files:**
- Create: `app/Infrastructure/Http/MultipartUpload.php`
- Create: `app/Infrastructure/Http/MultipartRequestFactory.php`
- Modify: `app/Infrastructure/Http/ApiMyCodeHttpClient.php`
- Modify: `app/Application/Bridge/ProviderBridgeProxy.php`
- Modify: `app/Application/Bridge/ProviderProxyPayloadResolver.php`
- Create: `tests/Feature/ProviderBridgeUploadTest.php`

- [ ] **Step 1: Write failing upload tests**

```php
public function test_logo_is_forwarded_as_multipart_without_exposing_token(): void
{
    $providerId = '11111111-1111-4111-8111-111111111111';
    Http::fake(["https://api.mycode.cl/api/v1/service-providers/{$providerId}/media" => Http::response([
        'data' => ['logo_url' => 'https://cdn.example/logo.webp'],
    ])]);

    $response = $this->withProviderSession()->post(
        "/api/bridge/provider/service-providers/{$providerId}/media",
        ['logo' => UploadedFile::fake()->image('logo.png')],
        ['Accept' => 'application/json'],
    );

    $response->assertOk()->assertJsonMissing(['access_token']);
    Http::assertSent(fn (Request $request) => $request->isMultipart());
}
```

Add size/type rejection tests for logo 4 MB, cover/image 8 MB, and
verification documents 10 MB.

- [ ] **Step 2: Run the upload tests**

Run: `php artisan test tests/Feature/ProviderBridgeUploadTest.php`

Expected: FAIL because multipart forwarding is not implemented.

- [ ] **Step 3: Implement typed upload validation**

`ProviderProxyPayloadResolver` returns `MultipartUpload` objects for:

```php
[
    'provider_logo' => ['file' => 'logo', 'mimes' => ['jpg', 'jpeg', 'png', 'webp'], 'max_kb' => 4096],
    'provider_cover' => ['file' => 'cover', 'mimes' => ['jpg', 'jpeg', 'png', 'webp'], 'max_kb' => 8192],
    'location_image' => ['file' => 'image', 'mimes' => ['jpg', 'jpeg', 'png', 'webp'], 'max_kb' => 8192],
    'verification' => ['file' => 'document', 'mimes' => ['pdf', 'jpg', 'jpeg', 'png'], 'max_kb' => 10240],
]
```

Provider media accepts exactly one of `logo` or `cover`. Location images also
accept `type`, `alt_text`, and `sort_order`; verifications accept
`verification_type` and optional `metadata`.

- [ ] **Step 4: Implement streamed forwarding**

`MultipartRequestFactory` calls Laravel HTTP client `attach()` with
`$uploadedFile->readStream()`, original filename, and MIME type, then adds
scalar fields. `ApiMyCodeHttpClient::requestMultipart()` sends to the
validated endpoint with the session authorization header.

- [ ] **Step 5: Run tests**

Run: `php artisan test tests/Feature/ProviderBridgeUploadTest.php`

Expected: PASS without base64 conversion and without JWT leakage.

- [ ] **Step 6: Commit**

```bash
git add app/Infrastructure/Http app/Application/Bridge tests/Feature/ProviderBridgeUploadTest.php
git commit -m "feat: stream provider media and verification uploads"
```

Choose **Mantener**.

### Task 6: Portal shell and account screens

**Files:**
- Create: `app/Presentation/ViewModels/ProviderPortalConfigViewModel.php`
- Create: `app/Presentation/ViewModels/ProviderPortalConfigFactory.php`
- Create: `app/Presentation/Http/Controllers/Web/ProviderPortalController.php`
- Create: `resources/views/provider/portal.blade.php`
- Create: `resources/views/provider/partials/account.blade.php`
- Create: `resources/views/provider/partials/app-shell.blade.php`
- Create: `public/css/provider/portal.css`
- Create: `resources/js/provider/00-config.js`
- Create: `resources/js/provider/01-http.js`
- Create: `resources/js/provider/02-session.js`
- Create: `resources/js/provider/03-router.js`
- Modify: `webpack.mix.js`
- Modify: `routes/web.php`
- Create: `tests/Feature/ProviderPortalAccessTest.php`
- Create: `tests/js/provider-session.test.js`
- Modify: `package.json`

- [ ] **Step 1: Write failing shell and JS tests**

```php
public function test_login_shell_contains_no_api_token(): void
{
    $this->get('/mi-prestador/login')
        ->assertOk()
        ->assertSee('provider-root')
        ->assertDontSee('provider_api_token');
}

public function test_private_portal_redirects_without_session(): void
{
    $this->get('/mi-prestador')->assertRedirect('/mi-prestador/login');
}
```

The Node test must assert that `providerFetch()` uses same-origin credentials,
CSRF for mutations, and redirects to the portal login after 401.

- [ ] **Step 2: Run tests**

Run: `php artisan test tests/Feature/ProviderPortalAccessTest.php && node tests/js/provider-session.test.js`

Expected: FAIL because the shell and modules are absent.

- [ ] **Step 3: Build the shell and config**

The root exposes only same-origin routes:

```blade
<div id="provider-root"
    data-login-url="{{ $vm->loginUrl }}"
    data-dashboard-url="{{ $vm->dashboardUrl }}"
    data-bridge-url="{{ $vm->bridgeUrl }}"
    data-session-login-url="{{ $vm->sessionLoginUrl }}"
    data-session-status-url="{{ $vm->sessionStatusUrl }}"
    data-session-logout-url="{{ $vm->sessionLogoutUrl }}">
```

No API URL or token is required by client code.

- [ ] **Step 4: Add focused modules and build entries**

Each `resources/js/provider/*.js` remains below 150 lines. `webpack.mix.js`
wraps them into `public/js/provider/app.js`. Add:

```json
"test:provider": "node tests/js/provider-session.test.js && node tests/js/provider-payloads.test.js"
```

- [ ] **Step 5: Run tests and production build**

Run: `php artisan test tests/Feature/ProviderPortalAccessTest.php && npm run test:provider && npm run production`

Expected: PASS and generated `public/js/provider/app.js`.

- [ ] **Step 6: Commit**

```bash
git add app/Presentation resources/views/provider resources/js/provider public/css/provider public/js/provider webpack.mix.js routes/web.php tests package.json package-lock.json
git commit -m "feat: add provider portal shell and account ui"
```

Choose **Mantener**.

### Task 7: Provider identity and categories

**Files:**
- Create: `resources/js/provider/04-store.js`
- Create: `resources/js/provider/05-profile-view.js`
- Create: `resources/js/provider/06-profile-viewmodel.js`
- Create: `resources/js/provider/07-categories-view.js`
- Create: `resources/js/provider/08-categories-viewmodel.js`
- Create: `resources/views/provider/partials/profile.blade.php`
- Create: `resources/views/provider/partials/categories.blade.php`
- Create: `tests/js/provider-payloads.test.js`
- Create: `tests/Feature/ProviderPortalProfileTest.php`
- Modify: `webpack.mix.js`

- [ ] **Step 1: Write failing profile/category tests**

```javascript
assert.deepStrictEqual(
    buildProviderPayload(new FormData(profileForm)),
    {
        provider_type: 'business',
        display_name: 'Veterinaria MyCode',
        legal_name: 'Veterinaria MyCode SpA',
        email: 'contacto@example.com',
        phone: '+56220000000',
        mobile: '+56990000000',
        whatsapp: '+56990000000',
        website: 'https://example.com',
        instagram: null,
        facebook: null,
        description: 'Atención veterinaria integral',
    },
);
```

Add category fixture assertions using UUID strings for
`{category_id, is_primary}` and maximum 20 categories.

- [ ] **Step 2: Run tests**

Run: `node tests/js/provider-payloads.test.js && php artisan test tests/Feature/ProviderPortalProfileTest.php`

Expected: FAIL because views and builders do not exist.

- [ ] **Step 3: Implement profile create/update**

The viewmodel loads `/service-providers/mine`. If no provider exists it POSTs
`/service-providers`; otherwise it PUTs `/service-providers/{id}`. It stores
the returned resource in the central store and renders API 422 errors beside
matching controls.

- [ ] **Step 4: Implement catalogs and categories**

Load public `categories` through the read-only provider bridge catalog route
from Task 3, mapped to `/api/v1/public/service-catalogs/categories`.
Subcategories come from each category's `children` collection. Save categories
with:

```javascript
{ categories: selectedIds.map((id) => ({
    category_id: String(id),
    is_primary: String(id) === String(primaryId),
})) }
```

- [ ] **Step 5: Run tests and build**

Run: `npm run test:provider && php artisan test tests/Feature/ProviderPortalProfileTest.php && npm run production`

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add resources/js/provider resources/views/provider webpack.mix.js public/js/provider tests
git commit -m "feat: manage provider identity and categories"
```

Choose **Mantener**.

### Task 8: Locations and Google Maps picker

**Files:**
- Create: `resources/js/provider/09-location-payload.js`
- Create: `resources/js/provider/10-location-map.js`
- Create: `resources/js/provider/11-locations-view.js`
- Create: `resources/js/provider/12-locations-viewmodel.js`
- Create: `resources/views/provider/partials/locations.blade.php`
- Create: `resources/views/provider/partials/location-form.blade.php`
- Modify: `app/Presentation/ViewModels/ProviderPortalConfigViewModel.php`
- Modify: `app/Presentation/ViewModels/ProviderPortalConfigFactory.php`
- Modify: `config/services.php`
- Modify: `.env.example`
- Modify: `webpack.mix.js`
- Create: `tests/Feature/ProviderPortalLocationTest.php`
- Create: `tests/js/provider-location.test.js`

- [ ] **Step 1: Write failing location tests**

```javascript
assert.deepStrictEqual(buildLocationPayload(form), {
    name: 'Casa matriz',
    description: null,
    address_line: 'Av. Providencia 123',
    formatted_address: 'Av. Providencia 123, Providencia, Chile',
    commune: 'Providencia',
    region: 'Región Metropolitana',
    country_code: 'CL',
    latitude: -33.4263,
    longitude: -70.6132,
    geocoding_provider: 'google_maps',
    geocoding_place_id: 'place-id',
    location_source: 'map_pin',
    location_confirmed_by_user: true,
    timezone: 'America/Santiago',
    is_24_hours: false,
    has_emergency_service: true,
    provides_home_service: true,
    home_service_radius_km: 25,
});
```

PHP tests assert the key is read from config but not rendered when empty.

- [ ] **Step 2: Run tests**

Run: `node tests/js/provider-location.test.js && php artisan test tests/Feature/ProviderPortalLocationTest.php`

Expected: FAIL.

- [ ] **Step 3: Configure Google Maps**

```php
'google_maps' => [
    'api_key' => env('GOOGLE_MAPS_API_KEY', ''),
],
```

Add `GOOGLE_MAPS_API_KEY=` to `.env.example`. The ViewModel exposes the key
only to pages needing the SDK.

- [ ] **Step 4: Implement location CRUD and map adapter**

`10-location-map.js` exports `createLocationPicker(element, options)` with
`setPosition`, `destroy`, and `onChange`. It supports click/drag and fills
address, commune, region, place id, and formatted address from geocoding.
When no key exists, it renders manual address and coordinate inputs.

- [ ] **Step 5: Run tests and build**

Run: `npm run test:provider && php artisan test tests/Feature/ProviderPortalLocationTest.php && npm run production`

Expected: PASS with and without a Google Maps key.

- [ ] **Step 6: Commit**

```bash
git add resources/js/provider resources/views/provider app/Presentation/ViewModels config/services.php .env.example webpack.mix.js public/js/provider tests
git commit -m "feat: manage provider locations with map picker"
```

Choose **Mantener**.

### Task 9: Location operations, schedules, exceptions, and contacts

**Files:**
- Create: `resources/js/provider/13-operations-payloads.js`
- Create: `resources/js/provider/14-services-viewmodel.js`
- Create: `resources/js/provider/15-attributes-viewmodel.js`
- Create: `resources/js/provider/16-schedules-viewmodel.js`
- Create: `resources/js/provider/17-contacts-viewmodel.js`
- Create: `resources/views/provider/partials/services.blade.php`
- Create: `resources/views/provider/partials/attributes.blade.php`
- Create: `resources/views/provider/partials/schedules.blade.php`
- Create: `resources/views/provider/partials/contacts.blade.php`
- Modify: `webpack.mix.js`
- Extend: `tests/js/provider-payloads.test.js`
- Create: `tests/Feature/ProviderPortalOperationsTest.php`

- [ ] **Step 1: Write failing payload tests**

```javascript
assert.deepStrictEqual(buildServicePayload(serviceRow), {
    service_id: '11111111-1111-4111-8111-111111111111',
    is_available: true,
    price_from: 15000,
    price_to: 30000,
    currency: 'CLP',
    requires_appointment: true,
    notes: 'Incluye evaluación',
});

assert.deepStrictEqual(buildSchedulePayload(scheduleRow), {
    day_of_week: 1,
    opens_at: '09:00',
    closes_at: '18:00',
    is_closed: false,
    is_24_hours: false,
});
```

Add fixtures for specialty IDs, typed feature values, exceptions, and
contacts.

- [ ] **Step 2: Run tests**

Run: `npm run test:provider && php artisan test tests/Feature/ProviderPortalOperationsTest.php`

Expected: FAIL.

- [ ] **Step 3: Implement operations viewmodels**

Save complete collections with PUT:

- `/service-locations/{id}/services`
- `/service-locations/{id}/specialties`
- `/service-locations/{id}/features`
- `/service-locations/{id}/schedules`
- `/service-locations/{id}/schedule-exceptions`

Contacts use POST, PUT, and DELETE endpoints individually. Enforce one primary
contact in the client while retaining API validation as authority.

- [ ] **Step 4: Implement accessible views**

Every collection has add/remove controls, explicit labels, keyboard focus
after row creation, localized empty states, and field-level validation.
Schedule exceptions use native date/time inputs and announce conflicts.

- [ ] **Step 5: Run tests and build**

Run: `npm run test:provider && php artisan test tests/Feature/ProviderPortalOperationsTest.php && npm run production`

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add resources/js/provider resources/views/provider webpack.mix.js public/js/provider tests
git commit -m "feat: manage provider location operations"
```

Choose **Mantener**.

### Task 10: Media, verification, preview, and review submission

**Files:**
- Create: `resources/js/provider/18-media-viewmodel.js`
- Create: `resources/js/provider/19-verifications-viewmodel.js`
- Create: `resources/js/provider/20-review-viewmodel.js`
- Create: `resources/views/provider/partials/media.blade.php`
- Create: `resources/views/provider/partials/verifications.blade.php`
- Create: `resources/views/provider/partials/review.blade.php`
- Modify: `webpack.mix.js`
- Create: `tests/js/provider-review.test.js`
- Create: `tests/Feature/ProviderPortalReviewTest.php`

- [ ] **Step 1: Write failing review tests**

```javascript
assert.deepStrictEqual(providerCompletion(provider), {
    identity: true,
    categories: true,
    locations: true,
    services: true,
    schedules: true,
    contacts: true,
    media: true,
    verifications: false,
    canSubmit: true,
});
```

Feature tests cover media and verification uploads, deletion, submit-review,
and visible rejection/suspension reasons.

- [ ] **Step 2: Run tests**

Run: `node tests/js/provider-review.test.js && php artisan test tests/Feature/ProviderPortalReviewTest.php`

Expected: FAIL.

- [ ] **Step 3: Implement media and verification UI**

Use `FormData` against explicit bridge endpoints. Validate type and size before
request. Show upload progress state, preview returned URLs, and allow deletion
only when the API operation exists.

- [ ] **Step 4: Implement completion and submission**

`providerCompletion()` derives UI progress only; it does not override API
rules. The final action POSTs
`/service-providers/{id}/submit-review`, refreshes the resource, and renders
status plus rejection or suspension reason.

- [ ] **Step 5: Run portal suites and build**

Run:

```bash
php artisan test --filter=Provider
npm run test:provider
npm run production
php artisan architecture:audit --strict
```

Expected: all commands PASS.

- [ ] **Step 6: Commit**

```bash
git add resources/js/provider resources/views/provider webpack.mix.js public/js/provider tests
git commit -m "feat: complete provider review workflow"
```

Choose **Mantener**.

### Task 11: Portal regression and browser acceptance

**Files:**
- Modify: `tests/Feature/ArchitectureComplianceTest.php`
- Create: `docs/service-provider-portal-web.md`

- [ ] **Step 1: Add architecture assertions**

Assert all new PHP, Blade, and provider JS files are at most 250 lines, portal
Blade contains no inline scripts, and generated HTML contains none of:

```php
['provider_api_token', 'temporary_2fa_token', 'access_token']
```

- [ ] **Step 2: Run the full automated gate**

Run:

```bash
php artisan test
npm run test:admin-dashboard
npm run test:admin-users-status
npm run test:provider
npm run production
php artisan architecture:audit --strict
git diff --check
```

Expected: 0 failures, successful production build, clean diff check.

- [ ] **Step 3: Run browser acceptance**

Use local Laravel serving plus the browser tool to verify:

1. login without 2FA;
2. login with TOTP and recovery;
3. registration and email-verification screens;
4. complete provider profile;
5. create location manually and with map;
6. save operations, contacts, media, and verification;
7. submit for review;
8. no token appears in DOM, storage, or network response bodies.

- [ ] **Step 4: Document operation and deployment variables**

`docs/service-provider-portal-web.md` lists `API_MYCODE_URL`,
`API_MYCODE_ADMIN_JWT_SECRET`, encryption variables,
`GOOGLE_MAPS_API_KEY`, build command, cache clear command, and smoke routes.

- [ ] **Step 5: Commit**

```bash
git add tests/Feature/ArchitectureComplianceTest.php docs/service-provider-portal-web.md
git commit -m "test: verify provider portal end to end"
```

Choose **Mantener**.
