Files
portal/docs/superpowers/plans/2026-06-19-g7a-client-support-plan.md
T
2026-06-19 14:48:18 +03:00

31 KiB
Raw Blame History

G7-A — Клиентская «Помощь» — 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: Дать клиенту экран «Помощь» с email техподдержки + формой-заявкой (→ запись в БД support_requests + письмо в поддержку) + заготовкой JivoSite (config-driven).

Architecture: Backend — конфиг (services.php) + tenant-RLS таблица support_requests (schema.sql + guard'нутая миграция) + POST /api/support-requests (store в БД, затем Mail::queue в try/catch). Frontend — роут /help + пункт «Помощь» в сайдбаре + HelpView.vue (форма). JivoSite — условный <script> в shell-blade по env-ID. Спека: docs/superpowers/specs/2026-06-19-g7a-client-support-design.md.

Tech Stack: Laravel 13 / Pest 4 / PostgreSQL 16 (RLS, partitioning conventions); Vue 3 + Vuetify 3 / vue-tsc / eslint / Vite. Unisender Go (SMTP, очередь).

Окружение (факты):

  • Pest: composer --working-dir=app test -- <path>. Pint: composer --working-dir=app pint <files>.
  • Миграции: dev php app/artisan migrate --force; тест DB_DATABASE=liderra_testing php app/artisan migrate --force. Полная пересборка теста: DB_DATABASE=liderra_testing php app/artisan migrate:fresh --force.
  • Фронт: npm --prefix app run type-check / npm --prefix app run lint:vue / npm --prefix app run build. vitest сломан (G8) → фронт-юнитов нет, верификация = type-check+eslint+build+живой Playwright.
  • Коммит: LEFTHOOK=0 git commit -m "..." -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>". Push: git push gitea main.
  • ⚠ Репозиторий имеет ПРЕД-существующие грязные файлы (CLAUDE.md, .claude/settings.json, observer, CTemp*.txt) — НЕ трогать/не стейджить. Стейджить ТОЛЬКО явные пути, НИКОГДА git add -A.
  • Схема — источник истины: новые таблицы в db/schema.sql И в guard'нутую миграцию; после — migrate:fresh (test) зелёный + ZERO_DRIFT (0 таблиц БД отсутствуют в schema.sql).

Файловая карта

Backend (create): app/app/Models/SupportRequest.php, app/app/Http/Controllers/Api/SupportRequestController.php, app/app/Mail/SupportRequestMail.php, app/resources/views/emails/support_request.blade.php, app/database/migrations/2026_06_19_140000_create_support_requests.php, app/tests/Feature/Support/SupportRequestControllerTest.php. Backend (modify): app/config/services.php, app/.env.example, app/routes/web.php, db/schema.sql, db/CHANGELOG_schema.md, app/resources/views/welcome.blade.php. Frontend (create): app/resources/js/views/HelpView.vue, app/resources/js/api/support.ts. Frontend (modify): app/resources/js/router/index.ts, app/resources/js/components/layout/AppSidebar.vue.


Task 1: Backend config (support email + JivoSite)

Files: Modify app/config/services.php, app/.env.example.

  • Step 1: Add config keys

В app/config/services.php перед закрывающим ]; добавить:

    // G7-A: клиентская «Помощь».
    'support' => [
        'email' => env('SUPPORT_EMAIL', 'support@liderra.app'),
    ],
    'jivosite' => [
        'widget_id' => env('JIVO_WIDGET_ID'),
    ],
  • Step 2: .env.example

В app/.env.example добавить (рядом с MAIL_*):

SUPPORT_EMAIL=support@liderra.app
JIVO_WIDGET_ID=
  • Step 3: Verify config loads

Run: php app/artisan config:clear && php app/artisan tinker --execute="echo config('services.support.email').'|'.(config('services.jivosite.widget_id') ?? 'null');" Expected: support@liderra.app|null.

  • Step 4: Commit
git add app/config/services.php app/.env.example
LEFTHOOK=0 git commit -m "feat(G7-A): конфиг support.email + jivosite.widget_id" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: Таблица support_requests (schema.sql + миграция)

Files: Create app/database/migrations/2026_06_19_140000_create_support_requests.php; Modify db/schema.sql, db/CHANGELOG_schema.md.

  • Step 1: Migration (guarded, pgsql_supplier — паттерн tenant_requisites)

Create app/database/migrations/2026_06_19_140000_create_support_requests.php:

<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        $supplier = DB::connection('pgsql_supplier');

        $supplier->statement(<<<'SQL'
            CREATE TABLE IF NOT EXISTS support_requests (
                id         BIGSERIAL PRIMARY KEY,
                tenant_id  BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
                user_id    BIGINT NOT NULL REFERENCES users(id),
                name       VARCHAR(255) NOT NULL,
                contact    VARCHAR(255) NOT NULL,
                message    TEXT NOT NULL,
                created_at TIMESTAMPTZ NOT NULL DEFAULT now()
            )
        SQL);
        $supplier->statement('CREATE INDEX IF NOT EXISTS idx_support_requests_tenant ON support_requests (tenant_id, created_at DESC)');
        $supplier->statement('ALTER TABLE support_requests ENABLE ROW LEVEL SECURITY');
        // PG не поддерживает CREATE POLICY IF NOT EXISTS → дроп-перед-создание (идемпотентность к schema.sql).
        $supplier->statement('DROP POLICY IF EXISTS support_requests_tenant_isolation ON support_requests');
        $supplier->statement(<<<'SQL'
            CREATE POLICY support_requests_tenant_isolation ON support_requests
                USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
        SQL);
        foreach (['crm_app_user', 'crm_supplier_worker'] as $role) {
            $supplier->statement(<<<SQL
                DO \$\$
                BEGIN
                    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
                        GRANT SELECT, INSERT ON support_requests TO {$role};
                        GRANT USAGE, SELECT ON SEQUENCE support_requests_id_seq TO {$role};
                    END IF;
                END
                \$\$
            SQL);
        }
    }

    public function down(): void
    {
        DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS support_requests CASCADE');
    }
};
  • Step 2: Add the table to db/schema.sql

В db/schema.sql, в логичной секции (рядом с другими tenant-RLS клиентскими таблицами, напр. возле tenant_requisites), добавить:

CREATE TABLE support_requests (
    id         BIGSERIAL PRIMARY KEY,
    tenant_id  BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id    BIGINT NOT NULL REFERENCES users(id),
    name       VARCHAR(255) NOT NULL,
    contact    VARCHAR(255) NOT NULL,
    message    TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_support_requests_tenant ON support_requests (tenant_id, created_at DESC);
ALTER TABLE support_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY support_requests_tenant_isolation ON support_requests
    USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
GRANT SELECT, INSERT ON support_requests TO crm_app_user, crm_supplier_worker;
GRANT USAGE, SELECT ON SEQUENCE support_requests_id_seq TO crm_app_user, crm_supplier_worker;
COMMENT ON TABLE support_requests IS 'Заявки клиента в техподдержку (G7-A). RLS по tenant_id; разбор вручную (письмо + журнал).';

Match the file's indentation/comment style. Place near other tenant-RLS tables.

  • Step 3: Header metrics + version

В header db/schema.sql: tables +1 (regular), indexes +1, RLS policies +1. Бамп версии (читать текущую, напр. v8.47 → v8.48).

  • Step 4: CHANGELOG_schema.md

Добавить top-запись: версия, дата 2026-06-19, «G7-A: таблица support_requests (заявки клиента в техподдержку) — RLS tenant_isolation, индекс, GRANTs. Миграция 2026_06_19_140000 (guarded).»

  • Step 5: Apply on dev + test, verify migrate:fresh + ZERO_DRIFT

Run:

php app/artisan migrate --force
DB_DATABASE=liderra_testing php app/artisan migrate:fresh --force 2>&1 | tail -5

Expected: всё DONE, без «already exists». Затем ZERO_DRIFT:

DB_DATABASE=liderra_testing php app/artisan tinker --execute="\$db=collect(DB::select(\"SELECT tablename FROM pg_tables WHERE schemaname='public'\"))->pluck('tablename'); \$s=file_get_contents(dirname(base_path()).'/db/schema.sql'); \$m=[]; foreach(\$db as \$t){ if(preg_match('/_y20\\d\\d_m\\d\\d\$/',\$t))continue; if(in_array(\$t,['migrations','jobs','job_batches','failed_jobs','cache','cache_locks','sessions','password_reset_tokens']))continue; if(!preg_match('/CREATE TABLE '.\$t.'\\b/',\$s))\$m[]=\$t; } echo empty(\$m)?'ZERO_DRIFT':('STILL_MISSING: '.implode(',',\$m));"

Expected: ZERO_DRIFT. И grep -cE "CREATE TABLE support_requests\b" db/schema.sql → 1.

  • Step 6: Commit
git add app/database/migrations/2026_06_19_140000_create_support_requests.php db/schema.sql db/CHANGELOG_schema.md
LEFTHOOK=0 git commit -m "feat(G7-A): таблица support_requests (schema + миграция, RLS)" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: Модель SupportRequest

Files: Create app/app/Models/SupportRequest.php.

  • Step 1: Model (явные @property — ide-helper:models может пропустить)

Create app/app/Models/SupportRequest.php:

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;

/**
 * Заявка клиента в техподдержку (G7-A). RLS по tenant_id; created_at — DB default.
 *
 * @property int $id
 * @property int $tenant_id
 * @property int $user_id
 * @property string $name
 * @property string $contact
 * @property string $message
 * @property Carbon $created_at
 */
class SupportRequest extends Model
{
    protected $table = 'support_requests';

    public $timestamps = false; // только created_at (DB DEFAULT now()), updated_at нет

    protected $fillable = [
        'tenant_id', 'user_id', 'name', 'contact', 'message',
    ];

    protected function casts(): array
    {
        return ['created_at' => 'datetime'];
    }
}
  • Step 2: Commit
composer --working-dir=app pint app/Models/SupportRequest.php
git add app/app/Models/SupportRequest.php
LEFTHOOK=0 git commit -m "feat(G7-A): модель SupportRequest" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: Mailable + шаблон письма

Files: Create app/app/Mail/SupportRequestMail.php, app/resources/views/emails/support_request.blade.php.

  • Step 1: Mailable (паттерн NewLeadsDigestMail)

Create app/app/Mail/SupportRequestMail.php:

<?php

declare(strict_types=1);

namespace App\Mail;

use App\Models\SupportRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

/**
 * Письмо в техподдержку о новой заявке клиента (G7-A). Адресат — config('services.support.email').
 */
class SupportRequestMail extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function __construct(public SupportRequest $request) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Лидерра. Заявка в поддержку #'.$this->request->id,
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.support_request',
            with: ['r' => $this->request],
        );
    }
}
  • Step 2: Email blade

Create app/resources/views/emails/support_request.blade.php:

<p>Новая заявка в техподдержку Лидерры.</p>
<ul>
    <li><strong>Заявка #:</strong> {{ $r->id }}</li>
    <li><strong>Тенант:</strong> {{ $r->tenant_id }}</li>
    <li><strong>Пользователь:</strong> {{ $r->user_id }}</li>
    <li><strong>Имя:</strong> {{ $r->name }}</li>
    <li><strong>Контакт:</strong> {{ $r->contact }}</li>
    <li><strong>Время:</strong> {{ $r->created_at?->format('d.m.Y H:i') }}</li>
</ul>
<p><strong>Сообщение:</strong></p>
<p>{{ $r->message }}</p>
  • Step 3: Commit
composer --working-dir=app pint app/Mail/SupportRequestMail.php
git add app/app/Mail/SupportRequestMail.php app/resources/views/emails/support_request.blade.php
LEFTHOOK=0 git commit -m "feat(G7-A): SupportRequestMail + шаблон письма" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: Контроллер + роут (TDD)

Files: Create app/app/Http/Controllers/Api/SupportRequestController.php, app/tests/Feature/Support/SupportRequestControllerTest.php; Modify app/routes/web.php.

  • Step 1: Write failing test

Create app/tests/Feature/Support/SupportRequestControllerTest.php:

<?php

declare(strict_types=1);

use App\Mail\SupportRequestMail;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Mail;

use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;

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

it('создаёт заявку в БД и ставит письмо в очередь', function () {
    actingAs($this->user);

    postJson('/api/support-requests', [
        'name' => 'Иван',
        'contact' => '+79991234567',
        'message' => 'Не приходят лиды.',
    ])->assertCreated()->assertJson(['ok' => true]);

    $row = SupportRequest::query()->where('tenant_id', $this->tenant->id)->first();
    expect($row)->not->toBeNull()
        ->and($row->user_id)->toBe($this->user->id)
        ->and($row->name)->toBe('Иван')
        ->and($row->contact)->toBe('+79991234567')
        ->and($row->message)->toBe('Не приходят лиды.');

    Mail::assertQueued(SupportRequestMail::class, fn (SupportRequestMail $m) => $m->hasTo(config('services.support.email')));
});

it('валидирует обязательные поля (422)', function () {
    actingAs($this->user);
    postJson('/api/support-requests', ['name' => '', 'contact' => '', 'message' => ''])
        ->assertStatus(422)
        ->assertJsonValidationErrors(['name', 'contact', 'message']);
});

it('не теряет заявку при сбое почты', function () {
    actingAs($this->user);
    Mail::shouldReceive('queue')->andThrow(new RuntimeException('smtp down'));

    postJson('/api/support-requests', [
        'name' => 'Пётр', 'contact' => 'p@example.org', 'message' => 'Вопрос.',
    ])->assertCreated();

    expect(SupportRequest::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
  • Step 2: Run — verify it fails (no route/controller)

Run: composer --working-dir=app test -- tests/Feature/Support/SupportRequestControllerTest.php Expected: FAIL (404 / route not defined).

  • Step 3: Controller

Create app/app/Http/Controllers/Api/SupportRequestController.php:

<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Mail\SupportRequestMail;
use App\Models\SupportRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;

/**
 * G7-A: приём клиентских заявок в техподдержку. Запись в БД — основной канал;
 * письмо в поддержку — best-effort (сбой SMTP не валит запрос, паттерн G1 sendCode).
 */
class SupportRequestController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'contact' => 'required|string|max:255',
            'message' => 'required|string|max:5000',
        ]);

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

        $supportRequest = DB::transaction(function () use ($user, $validated): SupportRequest {
            DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);

            return SupportRequest::create([
                'tenant_id' => $user->tenant_id,
                'user_id' => $user->id,
                'name' => $validated['name'],
                'contact' => $validated['contact'],
                'message' => $validated['message'],
            ]);
        });

        // Письмо — best-effort: заявка уже в БД, сбой почты не теряет её и не валит запрос.
        try {
            Mail::to(config('services.support.email'))->queue(new SupportRequestMail($supportRequest));
        } catch (\Throwable $e) {
            Log::warning('SupportRequestMail queue failed', ['id' => $supportRequest->id, 'error' => $e->getMessage()]);
        }

        return response()->json(['ok' => true], 201);
    }
}
  • Step 4: Route

В app/routes/web.php добавить (рядом с прочими auth:sanctum+tenant API-группами):

// G7-A: клиентские заявки в техподдержку.
Route::middleware(['auth:sanctum', 'tenant'])->post('/api/support-requests', 'App\Http\Controllers\Api\SupportRequestController@store');
  • Step 5: Run — verify pass

Run: composer --working-dir=app test -- tests/Feature/Support/SupportRequestControllerTest.php Expected: 3 passed.

  • Step 6: Commit
composer --working-dir=app pint app/Http/Controllers/Api/SupportRequestController.php
git add app/app/Http/Controllers/Api/SupportRequestController.php app/routes/web.php app/tests/Feature/Support/SupportRequestControllerTest.php
LEFTHOOK=0 git commit -m "feat(G7-A): POST /api/support-requests + тест (store+mail+валидация)" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: SPA-shell (meta support-email + JivoSite)

Files: Modify app/resources/views/welcome.blade.php.

  • Step 1: Inject meta + conditional JivoSite

Заменить <head>...</head> блок в app/resources/views/welcome.blade.php, добавив после <title>:

    <meta name="support-email" content="{{ config('services.support.email') }}">
    @vite(['resources/css/app.css', 'resources/js/app.ts'])
    @if(config('services.jivosite.widget_id'))
        <script src="https://code.jivo.ru/widget/{{ config('services.jivosite.widget_id') }}" async></script>
    @endif
</head>

(итог: <title><meta support-email>@vite → условный JivoSite → </head>). NB: SRI к JivoSite неприменим (динамический loader); контроль — CSP-allowlist code.jivo.ru при включении на проде (пункт деплоя, пока id пуст — скрипта нет).

  • Step 2: Verify blade renders (id пуст → нет скрипта)

Run: php app/artisan view:clear && curl -s http://127.0.0.1:8000/login | grep -c "support-email" (если сервер запущен; иначе пропустить — проверится в Playwright). Expected: 1 (meta есть), и нет code.jivo.ru (id пуст).

  • Step 3: Commit
git add app/resources/views/welcome.blade.php
LEFTHOOK=0 git commit -m "feat(G7-A): meta support-email + условный JivoSite в shell-blade" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 7: Фронт — роут /help + пункт «Помощь» в меню

Files: Modify app/resources/js/router/index.ts, app/resources/js/components/layout/AppSidebar.vue.

  • Step 1: Route

В app/resources/js/router/index.ts добавить (рядом с прочими app-роутами, напр. после /settings):

    {
        path: '/help',
        name: 'help',
        component: () => import('../views/HelpView.vue'),
        meta: {
            layout: 'app',
            title: 'Помощь',
            requiresAuth: true,
            transition: 'ld-route-fadeup',
            devIndex: 33,
            devLabel: 'Помощь',
        },
    },

(devIndex 33 — следующий свободный; если 33 занят, взять следующий незанятый.)

  • Step 2: Nav item «Помощь»

В app/resources/js/components/layout/AppSidebar.vue, в navGroups, в группу 'Команда' добавить пункт после «Настройки»:

    {
        eyebrow: 'Команда',
        items: [
            { title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
            { title: 'Помощь', icon: 'mdi-lifebuoy', to: '/help' },
        ],
    },
  • Step 3: type-check (упадёт — HelpView ещё нет; это ок до Task 8)

Пропустить прогон до Task 8 (HelpView создаётся там). Коммит — в конце Task 8.


Task 8: Фронт — HelpView + api/support.ts

Files: Create app/resources/js/views/HelpView.vue, app/resources/js/api/support.ts.

  • Step 1: api/support.ts

Create app/resources/js/api/support.ts:

import { apiClient } from './client';

export interface SupportRequestPayload {
    name: string;
    contact: string;
    message: string;
}

export async function submitSupportRequest(payload: SupportRequestPayload): Promise<void> {
    await apiClient.post('/api/support-requests', payload);
}

NB: проверить имя экспортируемого axios-инстанса в app/resources/js/api/client.ts (grep export); если он называется иначе (напр. api / default), импортировать соответствующе.

  • Step 2: HelpView.vue

Create app/resources/js/views/HelpView.vue:

<script setup lang="ts">
import { ref } from 'vue';
import { useAuthStore } from '../stores/auth';
import { submitSupportRequest } from '../api/support';

const auth = useAuthStore();

const supportEmail =
    document.querySelector('meta[name="support-email"]')?.getAttribute('content') ?? 'support@liderra.app';

const name = ref(
    [auth.user?.first_name, auth.user?.last_name].filter(Boolean).join(' ') || '',
);
const contact = ref(auth.user?.email ?? '');
const message = ref('');
const loading = ref(false);
const sent = ref(false);
const errorMsg = ref('');
const fieldErrors = ref<Record<string, string[]>>({});

async function submit() {
    errorMsg.value = '';
    fieldErrors.value = {};
    if (!name.value.trim() || !contact.value.trim() || !message.value.trim()) {
        errorMsg.value = 'Заполните все поля.';
        return;
    }
    loading.value = true;
    try {
        await submitSupportRequest({ name: name.value, contact: contact.value, message: message.value });
        sent.value = true;
        message.value = '';
    } catch (e: unknown) {
        const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
        if (err.response?.status === 422 && err.response.data?.errors) {
            fieldErrors.value = err.response.data.errors;
        } else {
            errorMsg.value = 'Не удалось отправить. Попробуйте ещё раз или напишите на почту.';
        }
    } finally {
        loading.value = false;
    }
}
</script>

<template>
    <div class="help-view pa-6" data-testid="help-view">
        <h1 class="text-h5 mb-1">Помощь</h1>
        <p class="text-body-2 text-medium-emphasis mb-6">
            Напишите нам  ответим на ваш контакт. Можно по почте, через форму ниже или в чат справа.
        </p>

        <v-card variant="outlined" class="pa-5 mb-4" max-width="640">
            <h3 class="text-subtitle-2 mb-2">Почта техподдержки</h3>
            <a :href="`mailto:${supportEmail}`" class="text-primary" data-testid="support-email">{{ supportEmail }}</a>
        </v-card>

        <v-card variant="outlined" class="pa-5" max-width="640">
            <h3 class="text-subtitle-2 mb-3">Оставить заявку</h3>

            <v-alert v-if="sent" type="success" variant="tonal" class="mb-4" data-testid="support-sent">
                Заявка отправлена. Мы свяжемся с вами по указанному контакту.
            </v-alert>
            <v-alert v-if="errorMsg" type="error" variant="tonal" class="mb-4">{{ errorMsg }}</v-alert>

            <v-text-field
                v-model="name"
                label="Имя"
                :error-messages="fieldErrors.name"
                density="comfortable"
                class="mb-2"
                data-testid="support-name"
            />
            <v-text-field
                v-model="contact"
                label="Контакт (телефон или email)"
                :error-messages="fieldErrors.contact"
                density="comfortable"
                class="mb-2"
                data-testid="support-contact"
            />
            <v-textarea
                v-model="message"
                label="Сообщение"
                :error-messages="fieldErrors.message"
                rows="4"
                density="comfortable"
                class="mb-3"
                data-testid="support-message"
            />

            <v-btn color="primary" :loading="loading" data-testid="support-submit" @click="submit">Отправить</v-btn>
        </v-card>
    </div>
</template>

NB: проверить, что auth.user имеет поля first_name/last_name/email (grep по stores/auth.ts / типу User); если имена иные — подставить фактические.

  • Step 3: type-check + eslint + build

Run: npm --prefix app run type-check && npm --prefix app run lint:vue && npm --prefix app run build Expected: 0 ошибок, build OK.

  • Step 4: Commit (роут+меню+экран вместе)
git add app/resources/js/router/index.ts app/resources/js/components/layout/AppSidebar.vue app/resources/js/views/HelpView.vue app/resources/js/api/support.ts
LEFTHOOK=0 git commit -m "feat(G7-A): экран «Помощь» (форма-заявка) + пункт меню + роут" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 9: Верификация

Files: только прогон.

  • Step 1: Backend Pest + adjacent

Run: composer --working-dir=app test -- tests/Feature/Support tests/Feature/Auth/NotificationPreferencesTest.php Expected: всё PASS (новый тест 3/3 + смежный регресс).

  • Step 2: stan + type-check + eslint + build

Run:

composer --working-dir=app stan
npm --prefix app run type-check
npm --prefix app run lint:vue
npm --prefix app run build

Expected: stan 0 (если новые ложноположительные Pest-методы в SupportRequestControllerTest — добавить в phpstan-baseline тем же паттерном, что прочие тесты; см. как в проекте baseline'ятся TestCall::postJson), type-check/eslint 0, build OK.

  • Step 3: migrate:fresh + ZERO_DRIFT (повтор после всех правок)

Run: DB_DATABASE=liderra_testing php app/artisan migrate:fresh --force 2>&1 | tail -3 + ZERO_DRIFT-скан из Task 2 Step 5. Expected: зелёный + ZERO_DRIFT.

  • Step 4: Живой Playwright

Поднять сервер (php app/artisan serve), пересобрать ассеты (npm --prefix app run build), залогиниться демо-клиентом (admin@demo.local / password, либо создать через самозапись). Через Playwright MCP на 127.0.0.1:8000:

  • (а) в боковом меню есть пункт «Помощь»; клик → /help.

  • (б) экран «Помощь»: виден email техподдержки (data-testid="support-email"), форма (имя/контакт/сообщение).

  • (в) заполнить сообщение, нажать «Отправить» → зелёный алерт data-testid="support-sent"; консоль без ошибок.

  • (г) проверить запись в БД: php app/artisan tinker --execute="echo App\Models\SupportRequest::count();" ≥ 1.

  • (д) JivoSite: id пуст → чата нет, экран рабочий (нет JS-ошибок про jivo).

  • Step 5: Финальный grep — нет хвостов

Run: grep -rin "support_requests\|SupportRequest" app/app | grep -vi "test" — ожидается: модель/контроллер/мейлабл/миграция (живые ссылки, без мусора).


Task 10: Push + память

  • Step 1: Push
git push gitea main

Expected: pre-push (gitleaks + lychee) проходит.

  • Step 2: Память

Обновить project-golive-findings-status.md: G7-A DONE (дата, что осталось — G7-B impersonation). Требует coverage: direct:memory-sync или фразу memory dump.


Self-Review плана (выполнено автором)

  • Покрытие спеки: §2.1 конфиг → Task 1; §2.2 таблица → Task 2; §2.3 модель/эндпоинт/письмо → Task 3/4/5; §2.4 shell → Task 6; §3 фронт → Task 7/8; §4 поток → реализован Task 5+8; §5 ошибки → Task 5 (try/catch, 422) + Task 8 (UI-состояния); §6 тесты → Task 5 (TDD) + Task 9. Пробелов нет.
  • Плейсхолдеры: нет TBD/«добавить валидацию» без кода — весь код приведён. NB-пометки (проверить имя axios-инстанса / поля User / devIndex) — это явные verify-инструкции, не пропуски.
  • Согласованность имён: таблица support_requests / модель SupportRequest / политика support_requests_tenant_isolation / эндпоинт POST /api/support-requests / config('services.support.email') / config('services.jivosite.widget_id') / meta[name=support-email] / submitSupportRequest — единообразно во всех задачах.