Files
portal/docs/superpowers/plans/2026-05-15-sprint2b-settings.md
T
Дмитрий a659390dd7 docs(plan): Sprint 2 Plan B — Settings (D1-D5 + J5 + J6)
Plan B of the Sprint 2 split — the Settings subsystem, 5 atomic TDD
tasks: PATCH /api/auth/me profile endpoint (J6); ProfileTab rewired to
real API (D1); ApiKey model + api-keys endpoints (J5/D3); outbound
webhook settings endpoints (J5/D4/D5); ApiTab full wiring (D2-D5).
Schema delta = 0 — api_keys + outbound_webhook_subscriptions tables
already exist in schema.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:51:46 +03:00

77 KiB
Raw Blame History

Sprint 2 Plan B — Settings subsystem (D1-D5 + J5 + J6) 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: Make the Settings → Профиль and Settings → API tabs fully functional from UI to DB: profile save (PATCH /api/auth/me), API-key copy/regenerate, and webhook URL save/test — backed by real endpoints and two new Eloquent models.

Architecture: Five atomic tasks. (1) AuthController::updateProfile adds PATCH /api/auth/me mirroring the existing updateNotificationPreferences. (2) ProfileTab.vue is rewired from hardcoded refs to the auth store + the new endpoint. (3) A new ApiKey model + ApiKeyController expose GET /api/api-keys and POST /api/api-keys/regenerate over the existing greenfield api_keys table. (4) A new OutboundWebhookSubscription model + WebhookSettingsController expose GET/PUT /api/tenants/me/webhook-settings and POST /api/webhooks/test over the existing greenfield outbound_webhook_subscriptions table. (5) ApiTab.vue is wired to two new frontend api modules. Schema delta = 0 — both tables already exist in db/schema.sql; no migration.

Tech Stack: Laravel 13 + Sanctum (routes in routes/web.php, no api.php), PostgreSQL 16 (RLS), Pest 4 (DatabaseTransactions); Vue 3 <script setup> + Vuetify 3 + Pinia, Vitest + @vue/test-utils.


Context for the implementer

  • Working directory: c:\моя\проекты\портал crm\Документация. Laravel app under app/: controllers app/app/Http/Controllers/Api/, models app/app/Models/, factories app/database/factories/, routes app/routes/web.php (there is no routes/api.php — Sanctum SPA uses the web group). Frontend app/resources/js/, frontend tests app/tests/Frontend/, backend tests app/tests/Feature/.
  • Branch: main (consistent with Sprint 1 + Plan A — implementers commit atomically to main). Do not push.
  • Commands:
    • Backend single file: cd app && php artisan test tests/Feature/<path> ; full suite: cd app && composer test:parallel
    • Frontend single file: cd app && npx vitest run tests/Frontend/<file> ; full: cd app && npm run test:vue
    • cd app && npm run type-check ; cd app && npm run lint:vue ; cd app && composer pint ; cd app && composer stan
  • Baseline (fresh, 2026-05-15, after Plan A): Pest 742/739/3sk/0, Vitest 92 files / 774 passed / 3 skipped / 0 failed, vue-tsc 0, ESLint 0.
  • Lefthook pre-commit runs gitleaks / markdownlint / cspell / eslint-vue / pint / larastan / squawk on staged files — must pass. Never bypass (--no-verify forbidden).
  • Do not stage or modify app/dev-indices.json (pre-existing uncommitted dev artifact). Stage only the files each task names.
  • The repo path contains Cyrillic — if an Edit reports success but content looks unchanged, re-Read and retry.
  • Verified facts (recon 2026-05-15):
    • User model $fillable already includes first_name, last_name, phone, timezonePATCH /me can mass-assign directly. Registration hard-codes first_name='Новый', last_name='Пользователь' — ProfileTab is where they get set; the users table has no role column (ProfileTab's role field was decorative mock).
    • api_keys and outbound_webhook_subscriptions tables exist in db/schema.sql with RLS, but zero application code touches them — no models, controllers, factories, or tests. Everything is created new.
    • No outbound webhook delivery pipeline exists (no Job/Service). The POST /api/webhooks/test endpoint therefore performs an unsigned connectivity test (real HTTP POST, report status) — HMAC-signed event delivery is an explicit post-MVP epic. The secret is still generated/stored (schema requires secret_hash NOT NULL) and shown once, so tenants can pre-configure their receiver.
    • Tenant-scoped routes use Route::middleware(['auth:sanctum', 'tenant']). tenant = SetTenantContext (resolves tenant_id from auth()->user(), runs SET LOCAL app.current_tenant_id inside a transaction). In Pest, actingAs($user) establishes both auth and tenant context.
    • PATCH /api/auth/me goes in the existing /api/auth auth:sanctum group (no tenant middleware) — mirroring the sibling updateNotificationPreferences, which already does $user->update(...) on the users table the same way.
    • Frontend api modules all import { apiClient, ensureCsrfCookie } from ./client (never raw axios); GETs skip ensureCsrfCookie(), mutations call it first.

File Structure

File Action Responsibility
app/app/Http/Controllers/Api/AuthController.php Modify +updateProfile(); userResource() +phone/timezone
app/routes/web.php Modify +PATCH /me; +/api/api-keys group; +webhook group
app/tests/Feature/Auth/UpdateProfileTest.php Create Pest test for PATCH /api/auth/me
app/resources/js/api/auth.ts Modify AuthUser +phone/timezone; +updateProfile() + payload type
app/resources/js/views/settings/ProfileTab.vue Modify Rewire to auth store + PATCH /me
app/tests/Frontend/ProfileTab.spec.ts Create Vitest spec for ProfileTab
app/app/Models/ApiKey.php Create Eloquent model for api_keys
app/database/factories/ApiKeyFactory.php Create Factory for ApiKey
app/app/Http/Controllers/Api/ApiKeyController.php Create index() + regenerate()
app/tests/Feature/ApiKeyControllerTest.php Create Pest test
app/app/Models/OutboundWebhookSubscription.php Create Eloquent model for outbound_webhook_subscriptions
app/database/factories/OutboundWebhookSubscriptionFactory.php Create Factory
app/app/Http/Controllers/Api/WebhookSettingsController.php Create show() + update() + test()
app/tests/Feature/WebhookSettingsControllerTest.php Create Pest test
app/resources/js/api/apiKeys.ts Create Frontend api module for api-keys
app/resources/js/api/webhooks.ts Create Frontend api module for webhook settings
app/resources/js/views/settings/ApiTab.vue Modify Full wiring of all 4 buttons
app/tests/Frontend/ApiTab.spec.ts Create Vitest spec for ApiTab

Task 1: PATCH /api/auth/me — profile update (closes J6)

Files:

  • Modify: app/app/Http/Controllers/Api/AuthController.php

  • Modify: app/routes/web.php

  • Test: app/tests/Feature/Auth/UpdateProfileTest.php

  • Step 1: Write the failing test

Create app/tests/Feature/Auth/UpdateProfileTest.php:

<?php

declare(strict_types=1);

use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create([
        'tenant_id' => $this->tenant->id,
        'first_name' => 'Новый',
        'last_name' => 'Пользователь',
        'phone' => null,
        'timezone' => 'Europe/Moscow',
    ]);
    $this->actingAs($this->user);
});

test('PATCH /api/auth/me обновляет профиль и возвращает user', function () {
    $response = $this->patchJson('/api/auth/me', [
        'first_name' => 'Иван',
        'last_name' => 'Петров',
        'phone' => '+7 916 000-00-00',
        'timezone' => 'Asia/Yekaterinburg',
    ]);

    $response->assertOk();
    expect($response->json('user.first_name'))->toBe('Иван');
    expect($response->json('user.last_name'))->toBe('Петров');
    expect($response->json('user.phone'))->toBe('+7 916 000-00-00');
    expect($response->json('user.timezone'))->toBe('Asia/Yekaterinburg');

    $this->user->refresh();
    expect($this->user->first_name)->toBe('Иван');
    expect($this->user->timezone)->toBe('Asia/Yekaterinburg');
});

test('PATCH /api/auth/me без auth: 401', function () {
    auth()->logout();
    $this->patchJson('/api/auth/me', [
        'first_name' => 'Иван',
        'last_name' => 'Петров',
        'timezone' => 'Europe/Moscow',
    ])->assertStatus(401);
});

test('PATCH /api/auth/me: 422 при пустом first_name', function () {
    $this->patchJson('/api/auth/me', [
        'first_name' => '',
        'last_name' => 'Петров',
        'timezone' => 'Europe/Moscow',
    ])->assertStatus(422)->assertJsonValidationErrorFor('first_name');
});

test('PATCH /api/auth/me: 422 при невалидной timezone', function () {
    $this->patchJson('/api/auth/me', [
        'first_name' => 'Иван',
        'last_name' => 'Петров',
        'timezone' => 'Mars/Olympus',
    ])->assertStatus(422)->assertJsonValidationErrorFor('timezone');
});

test('PATCH /api/auth/me: phone опционален (nullable)', function () {
    $response = $this->patchJson('/api/auth/me', [
        'first_name' => 'Иван',
        'last_name' => 'Петров',
        'timezone' => 'Europe/Moscow',
    ]);
    $response->assertOk();
    expect($response->json('user.phone'))->toBeNull();
});

test('GET /api/auth/me возвращает phone и timezone', function () {
    $response = $this->getJson('/api/auth/me');
    $response->assertOk();
    expect($response->json('user'))->toHaveKeys(['phone', 'timezone']);
});
  • Step 2: Run the test to verify it fails

Run: cd app && php artisan test tests/Feature/Auth/UpdateProfileTest.php Expected: FAIL — PATCH /api/auth/me is not routed (404 instead of 200/422), and GET /me does not yet return phone/timezone.

  • Step 3: Add the route in app/routes/web.php

Inside the existing Route::prefix('/api/auth')->group(...) → inner Route::middleware('auth:sanctum')->group(...) block, add Route::patch('/me', ...) right after the GET /me line. The block becomes:

    Route::middleware('auth:sanctum')->group(function () {
        Route::get('/me', 'App\Http\Controllers\Api\AuthController@me');
        Route::patch('/me', 'App\Http\Controllers\Api\AuthController@updateProfile');
        Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout');
        Route::patch('/me/notification-preferences', 'App\Http\Controllers\Api\AuthController@updateNotificationPreferences');
    });
  • Step 4: Add updateProfile() to AuthController.php

Add this method to app/app/Http/Controllers/Api/AuthController.php (place it next to updateNotificationPreferences, before the private userResource):

    /**
     * PATCH /api/auth/me — обновление профиля текущего пользователя
     * (имя, фамилия, телефон, тайм-зона). Email менять нельзя (через support).
     *
     * Audit J6/D1 (ProfileTab). Зеркалит updateNotificationPreferences:
     * та же группа auth:sanctum, тот же inline-validate, тот же userResource.
     */
    public function updateProfile(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'first_name' => ['required', 'string', 'max:255'],
            'last_name' => ['required', 'string', 'max:255'],
            'phone' => ['nullable', 'string', 'max:20'],
            'timezone' => ['required', 'timezone'],
        ]);

        /** @var User $user */
        $user = $request->user();
        $user->update($validated);

        return response()->json([
            'user' => $this->userResource($user->fresh()),
        ]);
    }
  • Step 5: Extend userResource() in AuthController.php

In the private userResource(User $user): array method, add phone and timezone keys (place them after last_name):

    /** @return array<string, mixed> */
    private function userResource(User $user): array
    {
        return [
            'id' => $user->id,
            'email' => $user->email,
            'first_name' => $user->first_name,
            'last_name' => $user->last_name,
            'phone' => $user->phone,
            'timezone' => $user->timezone,
            'tenant_id' => $user->tenant_id,
            'totp_enabled' => $user->totp_enabled,
            'last_login_at' => $user->last_login_at,
            'notification_preferences' => $user->notification_preferences,
            'sound_enabled' => $user->sound_enabled,
        ];
    }
  • Step 6: Run the test to verify it passes

Run: cd app && php artisan test tests/Feature/Auth/UpdateProfileTest.php Expected: PASS — 6/6.

  • Step 7: Pint + regression

Run: cd app && composer pint && composer test:parallel Expected: Pint clean; Pest 748/745/3sk/0 (baseline 742 + 6 new). 0 failures.

  • Step 8: Commit
git add app/app/Http/Controllers/Api/AuthController.php app/routes/web.php app/tests/Feature/Auth/UpdateProfileTest.php
git commit -m "feat(auth): PATCH /api/auth/me profile update endpoint (closes J6)

Audit J6: ProfileTab needs a full-profile update endpoint. Adds
AuthController::updateProfile (first_name/last_name/phone/timezone),
routed in the existing /api/auth auth:sanctum group; mirrors the
sibling updateNotificationPreferences. userResource() now also returns
phone + timezone so the GET /me round-trip carries them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: ProfileTab wired to real API (closes D1)

Files:

  • Modify: app/resources/js/api/auth.ts

  • Modify: app/resources/js/views/settings/ProfileTab.vue

  • Test: app/tests/Frontend/ProfileTab.spec.ts

  • Step 1: Write the failing test

Create app/tests/Frontend/ProfileTab.spec.ts:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';

vi.mock('../../resources/js/api/auth', () => ({
    updateProfile: vi.fn(),
}));

vi.mock('../../resources/js/api/client', () => ({
    apiClient: {},
    ensureCsrfCookie: vi.fn(),
    extractValidationErrors: vi.fn(() => null),
    extractErrorMessage: vi.fn((_e, fallback) => fallback ?? 'Произошла ошибка.'),
    extractRateLimitRetry: vi.fn(() => null),
}));

import * as authApi from '../../resources/js/api/auth';
import ProfileTab from '../../resources/js/views/settings/ProfileTab.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';

const vuetify = createVuetify();

const mockUser: AuthUser = {
    id: 1,
    email: 'ivan@example.ru',
    first_name: 'Иван',
    last_name: 'Петров',
    phone: '+7 916 000-00-00',
    timezone: 'Europe/Moscow',
    tenant_id: 1,
    totp_enabled: false,
    last_login_at: null,
};

const factory = (user: AuthUser | null = mockUser) => {
    setActivePinia(createPinia());
    const auth = useAuthStore();
    auth.user = user;
    return mount(ProfileTab, { global: { plugins: [vuetify] } });
};

describe('ProfileTab.vue', () => {
    beforeEach(() => {
        vi.clearAllMocks();
    });

    it('подставляет имя/фамилию/телефон/тайм-зону из auth-store', () => {
        const wrapper = factory();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.firstName).toBe('Иван');
        expect(vm.lastName).toBe('Петров');
        expect(vm.phone).toBe('+7 916 000-00-00');
        expect(vm.timezone).toBe('Europe/Moscow');
    });

    it('save() вызывает updateProfile с payload и показывает успех', async () => {
        (authApi.updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
            ...mockUser,
            first_name: 'Пётр',
        });
        const wrapper = factory();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        vm.firstName = 'Пётр';
        await vm.save();
        expect(authApi.updateProfile).toHaveBeenCalledWith({
            first_name: 'Пётр',
            last_name: 'Петров',
            phone: '+7 916 000-00-00',
            timezone: 'Europe/Moscow',
        });
        expect(vm.saveSuccess).toBe(true);
        expect(vm.saveError).toBe(null);
    });

    it('save() с пустым phone отправляет null', async () => {
        (authApi.updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
        const wrapper = factory();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        vm.phone = '   ';
        await vm.save();
        expect(authApi.updateProfile).toHaveBeenCalledWith(
            expect.objectContaining({ phone: null }),
        );
    });

    it('save() показывает ошибку при reject', async () => {
        (authApi.updateProfile as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
        const wrapper = factory();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        await vm.save();
        expect(vm.saveError).toBeTruthy();
        expect(vm.saveSuccess).toBe(false);
        expect(vm.saving).toBe(false);
    });

    it('resetForm() возвращает поля к значениям из store', async () => {
        const wrapper = factory();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        vm.firstName = 'Изменено';
        vm.resetForm();
        expect(vm.firstName).toBe('Иван');
    });
});
  • Step 2: Run the test to verify it fails

Run: cd app && npx vitest run tests/Frontend/ProfileTab.spec.ts Expected: FAIL — updateProfile is not yet exported from api/auth, and ProfileTab still uses hardcoded refs (no data-testid, no save/resetForm/saveSuccess).

  • Step 3: Extend app/resources/js/api/auth.ts

(a) In the AuthUser interface, add phone and timezone (place after last_name):

    phone?: string | null;
    timezone?: string | null;

(b) Add the payload interface and function (place near updateNotificationPreferences at the end of the file):

export interface UpdateProfilePayload {
    first_name: string;
    last_name: string;
    phone: string | null;
    timezone: string;
}

export async function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
    await ensureCsrfCookie();
    const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me', payload);
    return data.user;
}
  • Step 4: Replace app/resources/js/views/settings/ProfileTab.vue entirely
<script setup lang="ts">
/**
 * Settings → Профиль. Имя, фамилия, телефон, тайм-зона текущего пользователя
 * из auth-store; сохранение через PATCH /api/auth/me (audit D1 / J6).
 *
 * Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #profile.
 * Email — read-only (меняется через support). Поле «Роль» убрано: в таблице
 * users нет колонки роли (было декоративной mock-заглушкой). Кнопка смены
 * аватара пока без обработчика — загрузка аватара вне scope audit D1.
 */
import { computed, ref } from 'vue';
import { useAuthStore } from '../../stores/auth';
import { updateProfile } from '../../api/auth';
import { extractErrorMessage } from '../../api/client';

const auth = useAuthStore();

const firstName = ref(auth.user?.first_name ?? '');
const lastName = ref(auth.user?.last_name ?? '');
const phone = ref(auth.user?.phone ?? '');
const timezone = ref(auth.user?.timezone ?? 'Europe/Moscow');
const email = computed(() => auth.user?.email ?? '');

const initials = computed(() => {
    const f = (auth.user?.first_name ?? '').charAt(0);
    const l = (auth.user?.last_name ?? '').charAt(0);
    return (f + l).toUpperCase() || '—';
});

const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref<string | null>(null);

function resetForm(): void {
    firstName.value = auth.user?.first_name ?? '';
    lastName.value = auth.user?.last_name ?? '';
    phone.value = auth.user?.phone ?? '';
    timezone.value = auth.user?.timezone ?? 'Europe/Moscow';
    saveSuccess.value = false;
    saveError.value = null;
}

async function save(): Promise<void> {
    if (saving.value) return;
    saving.value = true;
    saveSuccess.value = false;
    saveError.value = null;
    try {
        const updated = await updateProfile({
            first_name: firstName.value,
            last_name: lastName.value,
            phone: phone.value.trim() === '' ? null : phone.value.trim(),
            timezone: timezone.value,
        });
        auth.user = { ...auth.user!, ...updated };
        saveSuccess.value = true;
    } catch (err) {
        saveError.value = extractErrorMessage(err, 'Не удалось сохранить профиль.');
    } finally {
        saving.value = false;
    }
}
</script>

<template>
    <div class="tab-content">
        <h2 class="tab-title text-h6 mb-4">Профиль</h2>

        <v-alert
            v-if="saveSuccess"
            type="success"
            variant="tonal"
            density="compact"
            class="mb-3"
            closable
            data-testid="profile-save-success"
            @click:close="saveSuccess = false"
        >
            Профиль сохранён.
        </v-alert>
        <v-alert
            v-if="saveError"
            type="warning"
            variant="tonal"
            density="compact"
            class="mb-3"
            closable
            data-testid="profile-save-error"
            @click:close="saveError = null"
        >
            {{ saveError }}
        </v-alert>

        <v-row class="profile-row">
            <v-col cols="auto">
                <v-avatar size="80" color="primary">
                    <span class="text-h5">{{ initials }}</span>
                </v-avatar>
                <v-btn variant="text" size="small" class="mt-2" prepend-icon="mdi-camera"> Сменить </v-btn>
            </v-col>
            <v-col>
                <v-row dense>
                    <v-col cols="12" md="6">
                        <v-text-field
                            v-model="firstName"
                            label="Имя"
                            variant="outlined"
                            density="comfortable"
                            data-testid="profile-first-name"
                        />
                    </v-col>
                    <v-col cols="12" md="6">
                        <v-text-field
                            v-model="lastName"
                            label="Фамилия"
                            variant="outlined"
                            density="comfortable"
                            data-testid="profile-last-name"
                        />
                    </v-col>
                    <v-col cols="12" md="6">
                        <v-text-field
                            :model-value="email"
                            label="Email"
                            type="email"
                            variant="outlined"
                            density="comfortable"
                            disabled
                            persistent-hint
                            hint="Email менять только через support — связан с авторизацией"
                        />
                    </v-col>
                    <v-col cols="12" md="6">
                        <v-text-field
                            v-model="phone"
                            label="Телефон"
                            variant="outlined"
                            density="comfortable"
                            data-testid="profile-phone"
                        />
                    </v-col>
                    <v-col cols="12" md="6">
                        <v-text-field
                            v-model="timezone"
                            label="Тайм-зона"
                            variant="outlined"
                            density="comfortable"
                            persistent-hint
                            hint="Используется в логах и напоминаниях"
                            data-testid="profile-timezone"
                        />
                    </v-col>
                </v-row>

                <div class="d-flex ga-2 mt-4">
                    <v-btn
                        color="primary"
                        variant="flat"
                        :loading="saving"
                        data-testid="profile-save-btn"
                        @click="save"
                    >
                        Сохранить
                    </v-btn>
                    <v-btn variant="text" :disabled="saving" data-testid="profile-cancel-btn" @click="resetForm">
                        Отмена
                    </v-btn>
                </div>
            </v-col>
        </v-row>
    </div>
</template>

<style scoped>
.tab-title {
    font-variation-settings: 'opsz' 18;
    letter-spacing: -0.005em;
}
.profile-row {
    align-items: flex-start;
}
</style>
  • Step 5: Run the test to verify it passes

Run: cd app && npx vitest run tests/Frontend/ProfileTab.spec.ts Expected: PASS — 5/5.

  • Step 6: Type-check + lint + full Vitest

Run: cd app && npm run type-check && npm run lint:vue && npm run test:vue Expected: tsc 0, ESLint 0, Vitest 0 failed (baseline 774 + 5 new = 779 passed, 93 files).

  • Step 7: Commit
git add app/resources/js/api/auth.ts app/resources/js/views/settings/ProfileTab.vue app/tests/Frontend/ProfileTab.spec.ts
git commit -m "feat(settings): ProfileTab wired to PATCH /api/auth/me (closes D1)

Audit D1: ProfileTab fields were hardcoded refs and the Save button had
no handler. Rewired to the auth store + a new api/auth updateProfile()
calling PATCH /api/auth/me. Single «Полное имя» field split into Имя +
Фамилия (matches users.first_name/last_name); decorative «Роль» field
removed (no such column). AuthUser type gains phone + timezone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: ApiKey model + api-keys endpoints (closes J5 part 1, enables D2/D3)

Files:

  • Create: app/app/Models/ApiKey.php

  • Create: app/database/factories/ApiKeyFactory.php

  • Create: app/app/Http/Controllers/Api/ApiKeyController.php

  • Modify: app/routes/web.php

  • Test: app/tests/Feature/ApiKeyControllerTest.php

  • Step 1: Write the failing test

Create app/tests/Feature/ApiKeyControllerTest.php:

<?php

declare(strict_types=1);

use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->actingAs($this->user);
});

test('GET /api/api-keys возвращает активные ключи тенанта', function () {
    ApiKey::factory()->create(['tenant_id' => $this->tenant->id, 'user_id' => $this->user->id]);

    $response = $this->getJson('/api/api-keys');

    $response->assertOk();
    expect($response->json('data'))->toHaveCount(1);
    expect($response->json('data.0'))->toHaveKeys(['id', 'name', 'key_prefix', 'last_used_at', 'created_at']);
    // key_hash НИКОГДА не отдаётся наружу.
    expect($response->json('data.0'))->not->toHaveKey('key_hash');
});

test('GET /api/api-keys без auth: 401', function () {
    auth()->logout();
    $this->getJson('/api/api-keys')->assertStatus(401);
});

test('GET /api/api-keys изолирован по тенанту', function () {
    $otherTenant = Tenant::factory()->create();
    $otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
    ApiKey::factory()->create(['tenant_id' => $otherTenant->id, 'user_id' => $otherUser->id]);

    $response = $this->getJson('/api/api-keys');

    $response->assertOk();
    expect($response->json('data'))->toHaveCount(0);
});

test('POST /api/api-keys/regenerate создаёт ключ и возвращает plaintext один раз', function () {
    $response = $this->postJson('/api/api-keys/regenerate');

    $response->assertStatus(201);
    expect($response->json('key'))->toStartWith('lpkapi_');
    expect($response->json('key_prefix'))->toBe(substr($response->json('key'), 0, 10));
    expect($response->json())->toHaveKeys(['id', 'name', 'key', 'key_prefix']);

    // В БД хранится только bcrypt-хэш — не plaintext.
    $row = ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->first();
    expect($row)->not->toBeNull();
    expect($row->key_hash)->not->toBe($response->json('key'));
    expect(Hash::check($response->json('key'), $row->key_hash))->toBeTrue();
});

test('POST /api/api-keys/regenerate деактивирует предыдущий активный ключ', function () {
    $old = ApiKey::factory()->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'is_active' => true,
    ]);

    $this->postJson('/api/api-keys/regenerate')->assertStatus(201);

    $old->refresh();
    expect($old->is_active)->toBeFalse();
    // Ровно один активный ключ после регенерации.
    expect(ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->count())->toBe(1);
});

test('POST /api/api-keys/regenerate без auth: 401', function () {
    auth()->logout();
    $this->postJson('/api/api-keys/regenerate')->assertStatus(401);
});
  • Step 2: Run the test to verify it fails

Run: cd app && php artisan test tests/Feature/ApiKeyControllerTest.php Expected: FAIL — App\Models\ApiKey class does not exist (and routes are unrouted).

  • Step 3: Create the model app/app/Models/ApiKey.php
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\ApiKeyFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * API-ключ тенанта (таблица api_keys). Tenant-aware, RLS на уровне БД.
 *
 * key_hash — bcrypt-хэш; оригинал ключа показывается ОДИН раз при генерации
 * (ApiKeyController::regenerate). key_prefix (10 символов) — для отображения
 * в UI. Таблица имеет только created_at (без updated_at).
 */
class ApiKey extends Model
{
    /** @use HasFactory<ApiKeyFactory> */
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'tenant_id',
        'user_id',
        'name',
        'key_hash',
        'key_prefix',
        'scopes',
        'last_used_at',
        'last_used_ip',
        'expires_at',
        'is_active',
        'created_at',
    ];

    protected $hidden = ['key_hash'];

    protected function casts(): array
    {
        return [
            'scopes' => 'array',
            'is_active' => 'boolean',
            'last_used_at' => 'datetime',
            'expires_at' => 'datetime',
            'created_at' => 'datetime',
        ];
    }

    /** @return BelongsTo<Tenant, $this> */
    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    /** @return BelongsTo<User, $this> */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
  • Step 4: Create the factory app/database/factories/ApiKeyFactory.php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

/**
 * @extends Factory<ApiKey>
 */
class ApiKeyFactory extends Factory
{
    protected $model = ApiKey::class;

    /**
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'tenant_id' => Tenant::factory(),
            'user_id' => User::factory(),
            'name' => 'API-ключ',
            'key_hash' => Hash::make(Str::random(48)),
            'key_prefix' => 'lpkapi_'.Str::lower(Str::random(3)),
            'scopes' => ['read'],
            'last_used_at' => null,
            'expires_at' => now()->addYear(),
            'is_active' => true,
            'created_at' => now(),
        ];
    }
}
  • Step 5: Create the controller app/app/Http/Controllers/Api/ApiKeyController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\ApiKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

/**
 * API-ключи тенанта (audit D2/D3/J5). Endpoints под auth:sanctum + tenant.
 *
 * Полный ключ показывается ОДИН раз — в ответе regenerate(). В БД хранится
 * только bcrypt key_hash + key_prefix (первые 10 символов для UI). У тенанта
 * поддерживается один активный ключ: regenerate деактивирует прежние.
 */
class ApiKeyController extends Controller
{
    private const KEY_PREFIX = 'lpkapi_';

    public function index(Request $request): JsonResponse
    {
        $tenantId = (int) $request->user()->tenant_id;

        // Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
        $keys = ApiKey::query()
            ->where('tenant_id', $tenantId)
            ->where('is_active', true)
            ->orderByDesc('created_at')
            ->get(['id', 'name', 'key_prefix', 'last_used_at', 'expires_at', 'created_at']);

        return response()->json(['data' => $keys]);
    }

    public function regenerate(Request $request): JsonResponse
    {
        $tenantId = (int) $request->user()->tenant_id;
        $userId = (int) $request->user()->id;

        // Один активный ключ на тенанта — прежние деактивируются.
        ApiKey::query()
            ->where('tenant_id', $tenantId)
            ->where('is_active', true)
            ->update(['is_active' => false]);

        $plainKey = self::KEY_PREFIX.Str::random(48);

        $key = ApiKey::query()->create([
            'tenant_id' => $tenantId,
            'user_id' => $userId,
            'name' => 'API-ключ',
            'key_hash' => Hash::make($plainKey),
            'key_prefix' => substr($plainKey, 0, 10),
            'scopes' => ['read'],
            'expires_at' => now()->addYear(),
            'is_active' => true,
            'created_at' => now(),
        ]);

        return response()->json([
            'id' => $key->id,
            'name' => $key->name,
            'key' => $plainKey,            // показывается ОДИН раз
            'key_prefix' => $key->key_prefix,
        ], Response::HTTP_CREATED);
    }
}
  • Step 6: Register routes in app/routes/web.php

Add a new tenant-scoped group (place it near the other auth:sanctum+tenant groups, e.g. after the /api/billing/charges group):

// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
    Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
    Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate');
});
  • Step 7: Run the test to verify it passes

Run: cd app && php artisan test tests/Feature/ApiKeyControllerTest.php Expected: PASS — 6/6.

  • Step 8: Pint + Larastan + regression

Run: cd app && composer pint && composer stan && composer test:parallel Expected: Pint clean; Larastan clean (no new errors); Pest 754/751/3sk/0 (after Task 1: 748 + 6 new). 0 failures.

  • Step 9: Commit
git add app/app/Models/ApiKey.php app/database/factories/ApiKeyFactory.php app/app/Http/Controllers/Api/ApiKeyController.php app/routes/web.php app/tests/Feature/ApiKeyControllerTest.php
git commit -m "feat(api): api_keys model + GET/regenerate endpoints (closes J5 part 1)

Audit J5/D3: the api_keys table existed in schema but had zero code.
Adds the ApiKey model + factory, and ApiKeyController with GET
/api/api-keys (list active keys, key_hash hidden) and POST
/api/api-keys/regenerate (deactivate prior + create new, full key
returned once, bcrypt-hashed in DB). Tenant-scoped via auth:sanctum +
tenant middleware (RLS on api_keys).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: OutboundWebhookSubscription model + webhook-settings endpoints (closes J5 part 2, enables D4/D5)

Files:

  • Create: app/app/Models/OutboundWebhookSubscription.php

  • Create: app/database/factories/OutboundWebhookSubscriptionFactory.php

  • Create: app/app/Http/Controllers/Api/WebhookSettingsController.php

  • Modify: app/routes/web.php

  • Test: app/tests/Feature/WebhookSettingsControllerTest.php

  • Step 1: Write the failing test

Create app/tests/Feature/WebhookSettingsControllerTest.php:

<?php

declare(strict_types=1);

use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
    $this->actingAs($this->user);
});

test('GET webhook-settings: null когда подписки нет', function () {
    $response = $this->getJson('/api/tenants/me/webhook-settings');
    $response->assertOk();
    expect($response->json('data'))->toBeNull();
});

test('GET webhook-settings возвращает подписку тенанта', function () {
    OutboundWebhookSubscription::factory()->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'target_url' => 'https://crm.example.ru/hook',
    ]);

    $response = $this->getJson('/api/tenants/me/webhook-settings');

    $response->assertOk();
    expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
    expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
    // secret_hash наружу не отдаётся.
    expect($response->json('data'))->not->toHaveKey('secret_hash');
});

test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
    $response = $this->putJson('/api/tenants/me/webhook-settings', [
        'target_url' => 'https://crm.example.ru/hook',
    ]);

    $response->assertOk();
    expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
    expect($response->json('data.secret'))->toStartWith('whsec_');
    expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();

    $row = OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->first();
    expect($row)->not->toBeNull();
    expect(Hash::check($response->json('data.secret'), $row->secret_hash))->toBeTrue();
});

test('PUT webhook-settings обновляет URL существующей подписки без нового secret', function () {
    OutboundWebhookSubscription::factory()->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'target_url' => 'https://old.example.ru/hook',
    ]);

    $response = $this->putJson('/api/tenants/me/webhook-settings', [
        'target_url' => 'https://new.example.ru/hook',
    ]);

    $response->assertOk();
    expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
    // secret НЕ перевыпускается при простом обновлении URL.
    expect($response->json('data'))->not->toHaveKey('secret');
    expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
});

test('PUT webhook-settings: 422 при не-https URL', function () {
    $this->putJson('/api/tenants/me/webhook-settings', [
        'target_url' => 'http://insecure.example.ru/hook',
    ])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
});

test('POST webhooks/test отправляет запрос и возвращает результат', function () {
    Http::fake(['*' => Http::response(['ok' => true], 200)]);
    OutboundWebhookSubscription::factory()->create([
        'tenant_id' => $this->tenant->id,
        'user_id' => $this->user->id,
        'target_url' => 'https://crm.example.ru/hook',
    ]);

    $response = $this->postJson('/api/webhooks/test');

    $response->assertOk();
    expect($response->json('ok'))->toBeTrue();
    expect($response->json('status'))->toBe(200);
    Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
});

test('POST webhooks/test: 422 когда подписки нет', function () {
    $this->postJson('/api/webhooks/test')->assertStatus(422);
});

test('GET webhook-settings без auth: 401', function () {
    auth()->logout();
    $this->getJson('/api/tenants/me/webhook-settings')->assertStatus(401);
});
  • Step 2: Run the test to verify it fails

Run: cd app && php artisan test tests/Feature/WebhookSettingsControllerTest.php Expected: FAIL — App\Models\OutboundWebhookSubscription does not exist.

  • Step 3: Create the model app/app/Models/OutboundWebhookSubscription.php
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\OutboundWebhookSubscriptionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * Исходящая webhook-подписка тенанта (таблица outbound_webhook_subscriptions).
 * Tenant-aware, RLS на уровне БД.
 *
 * secret_hash — bcrypt-хэш; оригинал секрета показывается ОДИН раз при
 * создании. events — JSONB-массив, CHECK требует ≥1 элемента.
 *
 * NB: outbound-доставка событий (подписанные webhook'и) — пост-MVP; пока
 * подписка хранит URL + секрет, а WebhookSettingsController::test делает
 * unsigned connectivity-проверку.
 */
class OutboundWebhookSubscription extends Model
{
    /** @use HasFactory<OutboundWebhookSubscriptionFactory> */
    use HasFactory;

    protected $fillable = [
        'tenant_id',
        'user_id',
        'name',
        'target_url',
        'secret_hash',
        'secret_prefix',
        'events',
        'custom_headers',
        'is_active',
        'paused_at',
    ];

    protected $hidden = ['secret_hash'];

    protected function casts(): array
    {
        return [
            'events' => 'array',
            'custom_headers' => 'array',
            'is_active' => 'boolean',
            'consecutive_failures' => 'integer',
            'paused_at' => 'datetime',
            'last_delivery_at' => 'datetime',
            'last_failure_at' => 'datetime',
            'created_at' => 'datetime',
            'updated_at' => 'datetime',
        ];
    }

    /** @return BelongsTo<Tenant, $this> */
    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    /** @return BelongsTo<User, $this> */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
  • Step 4: Create the factory app/database/factories/OutboundWebhookSubscriptionFactory.php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

/**
 * @extends Factory<OutboundWebhookSubscription>
 */
class OutboundWebhookSubscriptionFactory extends Factory
{
    protected $model = OutboundWebhookSubscription::class;

    /**
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'tenant_id' => Tenant::factory(),
            'user_id' => User::factory(),
            'name' => 'Webhook',
            'target_url' => 'https://'.fake()->domainName().'/webhook',
            'secret_hash' => Hash::make('whsec_'.Str::random(40)),
            'secret_prefix' => 'whsec_'.Str::lower(Str::random(4)),
            'events' => ['deal.created', 'deal.status_changed'],
            'is_active' => true,
        ];
    }
}
  • Step 5: Create the controller app/app/Http/Controllers/Api/WebhookSettingsController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\OutboundWebhookSubscription;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

/**
 * Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
 * Endpoints под auth:sanctum + tenant.
 *
 * Один подписка-ряд на тенанта. Секрет генерируется при создании и
 * показывается ОДИН раз (в БД — bcrypt secret_hash + secret_prefix).
 *
 * test(): MVP делает unsigned connectivity-проверку (реальный POST на
 * target_url, отчёт по HTTP-статусу). HMAC-подписанная доставка событий —
 * отдельный пост-MVP эпик (outbound-pipeline пока не построен).
 */
class WebhookSettingsController extends Controller
{
    private const SECRET_PREFIX = 'whsec_';

    /** @var list<string> События по умолчанию для новой подписки. */
    private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed'];

    public function show(Request $request): JsonResponse
    {
        $sub = $this->currentSubscription($request);

        if ($sub === null) {
            return response()->json(['data' => null]);
        }

        return response()->json(['data' => [
            'target_url' => $sub->target_url,
            'secret_prefix' => $sub->secret_prefix,
            'events' => $sub->events,
            'is_active' => $sub->is_active,
        ]]);
    }

    public function update(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
        ]);

        $sub = $this->currentSubscription($request);
        $plainSecret = null;

        if ($sub === null) {
            $plainSecret = self::SECRET_PREFIX.Str::random(40);
            $sub = OutboundWebhookSubscription::query()->create([
                'tenant_id' => (int) $request->user()->tenant_id,
                'user_id' => (int) $request->user()->id,
                'name' => 'Webhook',
                'target_url' => $validated['target_url'],
                'secret_hash' => Hash::make($plainSecret),
                'secret_prefix' => substr($plainSecret, 0, 10),
                'events' => self::DEFAULT_EVENTS,
                'is_active' => true,
            ]);
        } else {
            $sub->update(['target_url' => $validated['target_url']]);
        }

        $payload = [
            'target_url' => $sub->target_url,
            'secret_prefix' => $sub->secret_prefix,
            'events' => $sub->events,
            'is_active' => $sub->is_active,
        ];
        if ($plainSecret !== null) {
            $payload['secret'] = $plainSecret;   // показывается ОДИН раз
        }

        return response()->json(['data' => $payload]);
    }

    public function test(Request $request): JsonResponse
    {
        $sub = $this->currentSubscription($request);

        if ($sub === null) {
            return response()->json([
                'message' => 'Сначала сохраните URL webhook.',
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $testPayload = [
            'event' => 'webhook.test',
            'sent_at' => now()->toIso8601String(),
            'message' => 'Тестовая доставка webhook от Лидерра.',
        ];

        // MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
        // IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
        try {
            $response = Http::timeout(10)
                ->withHeaders(['X-Webhook-Event' => 'webhook.test'])
                ->post($sub->target_url, $testPayload);

            return response()->json([
                'ok' => $response->successful(),
                'status' => $response->status(),
                'message' => $response->successful()
                    ? "Тестовый запрос доставлен (HTTP {$response->status()})."
                    : "Endpoint ответил HTTP {$response->status()}.",
            ]);
        } catch (\Throwable $e) {
            return response()->json([
                'ok' => false,
                'status' => null,
                'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(),
            ]);
        }
    }

    private function currentSubscription(Request $request): ?OutboundWebhookSubscription
    {
        $tenantId = (int) $request->user()->tenant_id;

        // Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
        return OutboundWebhookSubscription::query()
            ->where('tenant_id', $tenantId)
            ->orderByDesc('id')
            ->first();
    }
}
  • Step 6: Register routes in app/routes/web.php

Add a new tenant-scoped group (next to the /api/api-keys group from Task 3):

// Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
    Route::get('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@show');
    Route::put('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@update');
    Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
});
  • Step 7: Run the test to verify it passes

Run: cd app && php artisan test tests/Feature/WebhookSettingsControllerTest.php Expected: PASS — 8/8.

  • Step 8: Pint + Larastan + regression

Run: cd app && composer pint && composer stan && composer test:parallel Expected: Pint clean; Larastan clean; Pest 762/759/3sk/0 (after Task 3: 754 + 8 new). 0 failures.

  • Step 9: Commit
git add app/app/Models/OutboundWebhookSubscription.php app/database/factories/OutboundWebhookSubscriptionFactory.php app/app/Http/Controllers/Api/WebhookSettingsController.php app/routes/web.php app/tests/Feature/WebhookSettingsControllerTest.php
git commit -m "feat(api): outbound webhook settings endpoints (closes J5 part 2)

Audit J5/D4/D5: the outbound_webhook_subscriptions table existed in
schema but had zero code. Adds the OutboundWebhookSubscription model +
factory and WebhookSettingsController with GET/PUT
/api/tenants/me/webhook-settings (one subscription per tenant; secret
generated + returned once on creation, bcrypt-hashed) and POST
/api/webhooks/test (unsigned connectivity check — HMAC-signed event
delivery is a separate post-MVP epic). Tenant-scoped via auth:sanctum +
tenant middleware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: ApiTab full wiring — copy / regenerate / save webhook / test (closes D2, D3, D4, D5)

Files:

  • Create: app/resources/js/api/apiKeys.ts

  • Create: app/resources/js/api/webhooks.ts

  • Modify: app/resources/js/views/settings/ApiTab.vue

  • Test: app/tests/Frontend/ApiTab.spec.ts

  • Step 1: Write the failing test

Create app/tests/Frontend/ApiTab.spec.ts:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';

vi.mock('../../resources/js/api/apiKeys', () => ({
    listApiKeys: vi.fn(),
    regenerateApiKey: vi.fn(),
}));

vi.mock('../../resources/js/api/webhooks', () => ({
    getWebhookSettings: vi.fn(),
    saveWebhookSettings: vi.fn(),
    testWebhook: vi.fn(),
}));

vi.mock('../../resources/js/api/client', () => ({
    apiClient: {},
    ensureCsrfCookie: vi.fn(),
    extractValidationErrors: vi.fn(() => null),
    extractErrorMessage: vi.fn((_e, fallback) => fallback ?? 'Произошла ошибка.'),
    extractRateLimitRetry: vi.fn(() => null),
}));

import * as apiKeysApi from '../../resources/js/api/apiKeys';
import * as webhooksApi from '../../resources/js/api/webhooks';
import ApiTab from '../../resources/js/views/settings/ApiTab.vue';

const vuetify = createVuetify();

const flush = () => new Promise((r) => setTimeout(r, 30));

const mountTab = () => mount(ApiTab, { global: { plugins: [vuetify] } });

describe('ApiTab.vue', () => {
    beforeEach(() => {
        vi.clearAllMocks();
        (apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([]);
        (webhooksApi.getWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue(null);
        Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
    });

    it('загружает и показывает префикс API-ключа', async () => {
        (apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
            { id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
        ]);
        const wrapper = mountTab();
        await flush();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.apiKeyExists).toBe(true);
        expect(vm.apiTokenDisplay).toBe('lpkapi_abc');
    });

    it('copyToken() пишет в буфер и открывает toast', async () => {
        (apiKeysApi.listApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue([
            { id: 1, name: 'API-ключ', key_prefix: 'lpkapi_abc', last_used_at: null, expires_at: null, created_at: null },
        ]);
        const wrapper = mountTab();
        await flush();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        await vm.copyToken();
        expect(navigator.clipboard.writeText).toHaveBeenCalledWith('lpkapi_abc');
        expect(vm.toastOpen).toBe(true);
    });

    it('confirmRegenerate() показывает полный новый ключ один раз', async () => {
        (apiKeysApi.regenerateApiKey as ReturnType<typeof vi.fn>).mockResolvedValue({
            id: 2,
            name: 'API-ключ',
            key: 'lpkapi_FULLNEWKEYVALUE0000000000',
            key_prefix: 'lpkapi_FUL',
        });
        const wrapper = mountTab();
        await flush();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        await vm.confirmRegenerate();
        expect(apiKeysApi.regenerateApiKey).toHaveBeenCalled();
        expect(vm.apiTokenDisplay).toBe('lpkapi_FULLNEWKEYVALUE0000000000');
        expect(vm.fullKeyShown).toBe(true);
    });

    it('загружает настройки webhook', async () => {
        (webhooksApi.getWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
            target_url: 'https://crm.example.ru/hook',
            secret_prefix: 'whsec_abc',
            events: ['deal.created'],
            is_active: true,
        });
        const wrapper = mountTab();
        await flush();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.webhookUrl).toBe('https://crm.example.ru/hook');
        expect(vm.secretDisplay).toBe('whsec_abc');
    });

    it('saveWebhook() вызывает saveWebhookSettings и показывает secret один раз', async () => {
        (webhooksApi.saveWebhookSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
            target_url: 'https://crm.example.ru/hook',
            secret_prefix: 'whsec_new',
            events: ['deal.created'],
            is_active: true,
            secret: 'whsec_FULLSECRETVALUE0000',
        });
        const wrapper = mountTab();
        await flush();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        vm.webhookUrl = 'https://crm.example.ru/hook';
        await vm.saveWebhook();
        expect(webhooksApi.saveWebhookSettings).toHaveBeenCalledWith({ target_url: 'https://crm.example.ru/hook' });
        expect(vm.secretDisplay).toBe('whsec_FULLSECRETVALUE0000');
        expect(vm.fullSecretShown).toBe(true);
        expect(vm.webhookSuccess).toBeTruthy();
    });

    it('runWebhookTest() вызывает testWebhook и открывает toast с результатом', async () => {
        (webhooksApi.testWebhook as ReturnType<typeof vi.fn>).mockResolvedValue({
            ok: true,
            status: 200,
            message: 'Тестовый запрос доставлен (HTTP 200).',
        });
        const wrapper = mountTab();
        await flush();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        await vm.runWebhookTest();
        expect(webhooksApi.testWebhook).toHaveBeenCalled();
        expect(vm.toastOpen).toBe(true);
        expect(vm.toastText).toContain('HTTP 200');
    });
});
  • Step 2: Run the test to verify it fails

Run: cd app && npx vitest run tests/Frontend/ApiTab.spec.ts Expected: FAIL — ../../resources/js/api/apiKeys and ../../resources/js/api/webhooks do not exist; ApiTab has none of the exposed members.

  • Step 3: Create app/resources/js/api/apiKeys.ts
import { apiClient, ensureCsrfCookie } from './client';

/**
 * API-ключи тенанта (audit D2/D3). Backend: ApiKeyController.
 * Полный ключ доступен только в ответе regenerateApiKey().
 */
export interface ApiKeyInfo {
    id: number;
    name: string;
    key_prefix: string;
    last_used_at: string | null;
    expires_at: string | null;
    created_at: string | null;
}

export interface RegeneratedApiKey {
    id: number;
    name: string;
    key: string;
    key_prefix: string;
}

export async function listApiKeys(): Promise<ApiKeyInfo[]> {
    const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys');
    return data.data;
}

export async function regenerateApiKey(): Promise<RegeneratedApiKey> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<RegeneratedApiKey>('/api/api-keys/regenerate');
    return data;
}
  • Step 4: Create app/resources/js/api/webhooks.ts
import { apiClient, ensureCsrfCookie } from './client';

/**
 * Настройки исходящего webhook'а тенанта (audit D4/D5).
 * Backend: WebhookSettingsController. Полный secret доступен только в ответе
 * saveWebhookSettings() при первом создании подписки.
 */
export interface WebhookSettings {
    target_url: string;
    secret_prefix: string;
    events: string[];
    is_active: boolean;
}

export interface SavedWebhookSettings extends WebhookSettings {
    secret?: string;
}

export interface WebhookTestResult {
    ok: boolean;
    status: number | null;
    message: string;
}

export async function getWebhookSettings(): Promise<WebhookSettings | null> {
    const { data } = await apiClient.get<{ data: WebhookSettings | null }>('/api/tenants/me/webhook-settings');
    return data.data;
}

export async function saveWebhookSettings(payload: { target_url: string }): Promise<SavedWebhookSettings> {
    await ensureCsrfCookie();
    const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload);
    return data.data;
}

export async function testWebhook(): Promise<WebhookTestResult> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<WebhookTestResult>('/api/webhooks/test');
    return data;
}
  • Step 5: Replace app/resources/js/views/settings/ApiTab.vue entirely
<script setup lang="ts">
/**
 * Settings → API и Webhook (audit D2/D3/D4/D5 + J5).
 *
 * API-ключ: GET /api/api-keys показывает key_prefix; «Копировать» — clipboard
 *   + toast; «Перегенерировать» — POST /api/api-keys/regenerate, полный ключ
 *   показывается ОДИН раз (затем доступен только префикс).
 * Webhook: GET/PUT /api/tenants/me/webhook-settings (target_url + secret_prefix),
 *   «Тестовый webhook» — POST /api/webhooks/test (реальный unsigned POST).
 *
 * Полный ключ и полный секрет показываются один раз — в БД только bcrypt-хэши.
 * Подписанная outbound-доставка событий — пост-MVP.
 */
import { onMounted, ref } from 'vue';
import { listApiKeys, regenerateApiKey } from '../../api/apiKeys';
import { getWebhookSettings, saveWebhookSettings, testWebhook } from '../../api/webhooks';
import { extractErrorMessage } from '../../api/client';

// --- API-ключ ---
const apiKeyExists = ref(false);
const apiTokenDisplay = ref('');
const fullKeyShown = ref(false);
const tokenVisible = ref(false);
const apiKeyError = ref<string | null>(null);
const regenDialogOpen = ref(false);
const regenerating = ref(false);

// --- Webhook ---
const webhookUrl = ref('');
const secretDisplay = ref('');
const fullSecretShown = ref(false);
const secretVisible = ref(false);
const webhookError = ref<string | null>(null);
const webhookSuccess = ref<string | null>(null);
const savingWebhook = ref(false);
const testingWebhook = ref(false);

// --- общий toast (D2 copy + D5 test) ---
const toastOpen = ref(false);
const toastText = ref('');
const toastColor = ref<'success' | 'error'>('success');

function showToast(text: string, color: 'success' | 'error' = 'success'): void {
    toastText.value = text;
    toastColor.value = color;
    toastOpen.value = true;
}

async function loadApiKey(): Promise<void> {
    apiKeyError.value = null;
    try {
        const keys = await listApiKeys();
        const active = keys[0];
        if (active) {
            apiKeyExists.value = true;
            apiTokenDisplay.value = active.key_prefix;
            fullKeyShown.value = false;
        } else {
            apiKeyExists.value = false;
            apiTokenDisplay.value = '';
        }
    } catch (err) {
        apiKeyError.value = extractErrorMessage(err, 'Не удалось загрузить API-ключ.');
    }
}

async function loadWebhook(): Promise<void> {
    webhookError.value = null;
    try {
        const settings = await getWebhookSettings();
        if (settings) {
            webhookUrl.value = settings.target_url;
            secretDisplay.value = settings.secret_prefix;
            fullSecretShown.value = false;
        }
    } catch (err) {
        webhookError.value = extractErrorMessage(err, 'Не удалось загрузить настройки webhook.');
    }
}

onMounted(() => {
    void loadApiKey();
    void loadWebhook();
});

async function copyToken(): Promise<void> {
    if (apiTokenDisplay.value === '') return;
    try {
        await navigator.clipboard.writeText(apiTokenDisplay.value);
        showToast('Скопировано в буфер обмена.', 'success');
    } catch {
        showToast('Не удалось скопировать — выделите и скопируйте вручную.', 'error');
    }
}

async function confirmRegenerate(): Promise<void> {
    regenerating.value = true;
    apiKeyError.value = null;
    try {
        const result = await regenerateApiKey();
        apiTokenDisplay.value = result.key;
        fullKeyShown.value = true;
        apiKeyExists.value = true;
        tokenVisible.value = true;
        regenDialogOpen.value = false;
    } catch (err) {
        apiKeyError.value = extractErrorMessage(err, 'Не удалось перегенерировать ключ.');
        regenDialogOpen.value = false;
    } finally {
        regenerating.value = false;
    }
}

async function saveWebhook(): Promise<void> {
    if (savingWebhook.value) return;
    savingWebhook.value = true;
    webhookError.value = null;
    webhookSuccess.value = null;
    try {
        const result = await saveWebhookSettings({ target_url: webhookUrl.value });
        if (result.secret) {
            secretDisplay.value = result.secret;
            fullSecretShown.value = true;
            secretVisible.value = true;
        } else {
            secretDisplay.value = result.secret_prefix;
        }
        webhookSuccess.value = 'Настройки webhook сохранены.';
    } catch (err) {
        webhookError.value = extractErrorMessage(err, 'Не удалось сохранить webhook.');
    } finally {
        savingWebhook.value = false;
    }
}

async function runWebhookTest(): Promise<void> {
    if (testingWebhook.value) return;
    testingWebhook.value = true;
    try {
        const result = await testWebhook();
        showToast(result.message, result.ok ? 'success' : 'error');
    } catch (err) {
        showToast(extractErrorMessage(err, 'Не удалось отправить тестовый запрос.'), 'error');
    } finally {
        testingWebhook.value = false;
    }
}
</script>

<template>
    <div class="tab-content">
        <h2 class="tab-title text-h6 mb-4">API и Webhook</h2>

        <v-card variant="outlined" class="pa-4 mb-4">
            <h3 class="text-subtitle-2 mb-3">API-ключ</h3>
            <p class="text-body-2 text-medium-emphasis mb-3">
                Используется для подписи запросов в публичный API CRM. После регенерации старый ключ перестаёт работать
                немедленно.
            </p>

            <v-alert
                v-if="apiKeyError"
                type="warning"
                variant="tonal"
                density="compact"
                class="mb-3"
                closable
                data-testid="api-key-error"
                @click:close="apiKeyError = null"
            >
                {{ apiKeyError }}
            </v-alert>
            <v-alert
                v-if="fullKeyShown"
                type="warning"
                variant="tonal"
                density="compact"
                class="mb-3"
                data-testid="api-key-once-notice"
            >
                Новый ключ показывается <strong>один раз</strong>. Скопируйте и сохраните его сейчас.
            </v-alert>

            <v-text-field
                :model-value="apiTokenDisplay"
                :type="tokenVisible ? 'text' : 'password'"
                :placeholder="apiKeyExists ? '' : 'Ключ ещё не создан — нажмите «Перегенерировать»'"
                readonly
                variant="outlined"
                density="comfortable"
                data-testid="api-key-field"
                :append-inner-icon="tokenVisible ? 'mdi-eye-off' : 'mdi-eye'"
                @click:append-inner="tokenVisible = !tokenVisible"
            />
            <div class="d-flex ga-2 mt-2">
                <v-btn
                    variant="outlined"
                    size="small"
                    prepend-icon="mdi-content-copy"
                    :disabled="apiTokenDisplay === ''"
                    data-testid="api-key-copy-btn"
                    @click="copyToken"
                >
                    Копировать
                </v-btn>
                <v-btn
                    variant="outlined"
                    size="small"
                    color="warning"
                    prepend-icon="mdi-refresh"
                    data-testid="api-key-regen-btn"
                    @click="regenDialogOpen = true"
                >
                    Перегенерировать
                </v-btn>
            </div>
        </v-card>

        <v-card variant="outlined" class="pa-4">
            <h3 class="text-subtitle-2 mb-3">Webhook для приёма лидов</h3>
            <p class="text-body-2 text-medium-emphasis mb-3">
                URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
                <code>(tenant_id, source_crm_id)</code> в окне 24 ч (антифрод по phone  §10.8.1).
            </p>

            <v-alert
                v-if="webhookError"
                type="warning"
                variant="tonal"
                density="compact"
                class="mb-3"
                closable
                data-testid="webhook-error"
                @click:close="webhookError = null"
            >
                {{ webhookError }}
            </v-alert>
            <v-alert
                v-if="webhookSuccess"
                type="success"
                variant="tonal"
                density="compact"
                class="mb-3"
                closable
                data-testid="webhook-success"
                @click:close="webhookSuccess = null"
            >
                {{ webhookSuccess }}
            </v-alert>
            <v-alert
                v-if="fullSecretShown"
                type="warning"
                variant="tonal"
                density="compact"
                class="mb-3"
                data-testid="webhook-secret-once-notice"
            >
                Signing secret показывается <strong>один раз</strong>. Сохраните его в настройках вашего приёмника.
            </v-alert>

            <v-text-field
                v-model="webhookUrl"
                label="Endpoint URL"
                placeholder="https://..."
                variant="outlined"
                density="comfortable"
                data-testid="webhook-url-field"
            />
            <v-text-field
                :model-value="secretDisplay"
                :type="secretVisible ? 'text' : 'password'"
                label="Signing secret (HMAC)"
                :placeholder="secretDisplay ? '' : 'Появится после первого сохранения URL'"
                readonly
                variant="outlined"
                density="comfortable"
                data-testid="webhook-secret-field"
                :append-inner-icon="secretVisible ? 'mdi-eye-off' : 'mdi-eye'"
                @click:append-inner="secretVisible = !secretVisible"
            />
            <div class="d-flex ga-2 mt-2">
                <v-btn
                    color="primary"
                    variant="flat"
                    size="small"
                    :loading="savingWebhook"
                    data-testid="webhook-save-btn"
                    @click="saveWebhook"
                >
                    Сохранить
                </v-btn>
                <v-btn
                    variant="outlined"
                    size="small"
                    prepend-icon="mdi-test-tube"
                    :loading="testingWebhook"
                    data-testid="webhook-test-btn"
                    @click="runWebhookTest"
                >
                    Тестовый webhook
                </v-btn>
            </div>
        </v-card>

        <v-dialog v-model="regenDialogOpen" :max-width="440" data-testid="regen-dialog">
            <v-card>
                <v-card-title>Перегенерация API-ключа</v-card-title>
                <v-card-text>
                    Текущий ключ перестанет работать немедленно. Все интеграции с ним нужно будет обновить. Продолжить?
                </v-card-text>
                <v-card-actions>
                    <v-spacer />
                    <v-btn variant="text" :disabled="regenerating" @click="regenDialogOpen = false">Отмена</v-btn>
                    <v-btn
                        color="warning"
                        variant="flat"
                        :loading="regenerating"
                        data-testid="regen-confirm-btn"
                        @click="confirmRegenerate"
                    >
                        Перегенерировать
                    </v-btn>
                </v-card-actions>
            </v-card>
        </v-dialog>

        <v-snackbar
            v-model="toastOpen"
            :timeout="4000"
            :color="toastColor"
            location="bottom right"
            data-testid="api-tab-toast"
        >
            {{ toastText }}
        </v-snackbar>
    </div>
</template>

<style scoped>
.tab-title {
    font-variation-settings: 'opsz' 18;
    letter-spacing: -0.005em;
}
</style>
  • Step 6: Run the test to verify it passes

Run: cd app && npx vitest run tests/Frontend/ApiTab.spec.ts Expected: PASS — 6/6.

  • Step 7: Type-check + lint + full Vitest

Run: cd app && npm run type-check && npm run lint:vue && npm run test:vue Expected: tsc 0, ESLint 0, Vitest 0 failed (after Task 2: 779 passed / 93 files; this task adds ApiTab.spec.ts = +1 file / +6 tests → 785 passed / 94 files).

  • Step 8: Commit
git add app/resources/js/api/apiKeys.ts app/resources/js/api/webhooks.ts app/resources/js/views/settings/ApiTab.vue app/tests/Frontend/ApiTab.spec.ts
git commit -m "feat(settings): ApiTab wired to api-key + webhook endpoints (closes D2-D5)

Audit D2/D3/D4/D5: all four ApiTab buttons were handler-less and the
fields were hardcoded. Adds api/apiKeys.ts + api/webhooks.ts modules and
rewires ApiTab: loads the api-key prefix + webhook settings on mount;
Copy -> clipboard + snackbar; Regenerate -> confirm dialog -> POST
regenerate (full key shown once); Save Webhook -> PUT webhook-settings;
Test Webhook -> POST test with the result in a snackbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Final Acceptance — Plan B

After all 5 tasks:

  • Pest --parallel: 762/759/3sk/0 (baseline 742 + 6 + 6 + 8 = 762). 0 failures, 0 regressions.
  • Vitest: 94 files / 785 passed / 3 skipped / 0 failed (baseline 92/774 + ProfileTab.spec 5 + ApiTab.spec 6 = 94/785).
  • vue-tsc: 0 errors. ESLint: 0 errors. Pint: clean. Larastan: no new errors.
  • Lefthook pre-commit green on all 5 commits.
  • 5 atomic commits on main; not pushed.
  • Schema delta = 0 — db/schema.sql untouched, no migration added.

Spec coverage (audit 2026-05-15-portal-audit-design.md)

Audit ID Spec line This plan
D1 ProfileTab Save → PATCH /api/auth/me Task 2
D2 ApiTab Copy → clipboard + toast Task 5
D3 ApiTab Regenerate → POST /api/api-keys/regenerate Tasks 3 + 5
D4 ApiTab Save Webhook → PUT /api/tenants/me/webhook-settings Tasks 4 + 5
D5 ApiTab Test Webhook → POST /api/webhooks/test Tasks 4 + 5
J5 3 new backend endpoints (api-keys/regenerate, webhook-settings, webhook-test) Tasks 3 + 4 (+ supporting GET list/show endpoints)
J6 extend PATCH /api/auth/me for full profile Task 1

Design decisions baked in (controller-resolved during recon, 2026-05-15):

  • D4 webhook settings persist to the existing outbound_webhook_subscriptions table (purpose-built: URL + secret + events) — no schema change, no new tenants columns.
  • POST /api/webhooks/test is an unsigned connectivity test (real HTTP POST, reports HTTP status). HMAC-signed outbound event delivery is a separate post-MVP epic — the outbound delivery pipeline (Job/Service) does not exist yet. The secret is still generated, stored (bcrypt), and shown once so tenants can pre-configure their receiver.
  • ProfileTab: single «Полное имя» field split into Имя + Фамилия (matches users.first_name/last_name); the decorative «Роль» field is removed (no users.role column). The avatar «Сменить» button stays handler-less — avatar upload is out of audit D1 scope.
  • D6/D7 (SettingsView placeholder tabs, left-rail hide-if-not-implemented) are not in Plan B — they are separate audit items routed to Sprint 3 per the audit spec Sprint Schedule.