Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 944a85dcc8 | |||
| 9ebc20ff94 | |||
| 28d2d38857 | |||
| 09f16bd83c | |||
| 512d8e0e24 | |||
| 7aa0e4169e | |||
| 7c9a8151f6 | |||
| be36fc64b3 | |||
| d883bf486f | |||
| 8907d16e40 | |||
| 364065a239 | |||
| 000bf816cc | |||
| 339c5f09f7 | |||
| 7a49291296 | |||
| e3f6227ed1 | |||
| 7b8535eef2 | |||
| 69c1c5b374 | |||
| 8e804cc482 | |||
| 0bf69ce6b5 | |||
| 07747713f0 | |||
| c6d2df908a | |||
| d4ade05446 |
@@ -9,6 +9,25 @@ on:
|
||||
jobs:
|
||||
a11y:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
# Полноценный PostgreSQL для CI: схема Лидерры — чисто PG (RLS, партиции, роли,
|
||||
# raw schema.sql через load_initial_schema), на SQLite не грузится. Без живой БД
|
||||
# 14 авторизованных Pa11y-маршрутов не могут залогиниться под admin@demo.local.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: liderra
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -21,22 +40,39 @@ jobs:
|
||||
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node 20
|
||||
- name: Setup Node 22
|
||||
# Node 22 (>=22.18): корневые tooling-пакеты @cspell/*@10 требуют node>=22.18.
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install root JS deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
# npm install (не ci): корневой package-lock рассинхронен (gcp-metadata) — pre-existing долг.
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Install app composer deps
|
||||
working-directory: app
|
||||
run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Install app JS deps
|
||||
# --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7, установлено vite 8.
|
||||
working-directory: app
|
||||
run: npm ci --no-audit --no-fund
|
||||
run: npm ci --no-audit --no-fund --legacy-peer-deps
|
||||
|
||||
- name: Create PostgreSQL roles
|
||||
# schema.sql грузится без ролей (GRANT'ы в DO $$ EXISTS-guard), но поздние миграции
|
||||
# делают необёрнутый GRANT ... TO crm_app_user/crm_supplier_worker → роли нужны.
|
||||
env:
|
||||
PGPASSWORD: postgres
|
||||
run: |
|
||||
psql -h 127.0.0.1 -U postgres -d liderra -v ON_ERROR_STOP=1 \
|
||||
-v crm_app_password=ci_pa11y \
|
||||
-v crm_admin_password=ci_pa11y \
|
||||
-v crm_migrator_password=ci_pa11y \
|
||||
-v crm_audit_writer_password=ci_pa11y \
|
||||
-v crm_supplier_worker_password=ci_pa11y \
|
||||
-f db/00_create_roles.sql
|
||||
|
||||
- name: Bootstrap .env + key
|
||||
working-directory: app
|
||||
@@ -44,19 +80,60 @@ jobs:
|
||||
cp .env.example .env
|
||||
php artisan key:generate --force
|
||||
|
||||
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
|
||||
- name: Configure .env for CI PostgreSQL + Sanctum SPA
|
||||
# phpdotenv: первое вхождение ключа выигрывает → удаляем строку и дописываем заново.
|
||||
# APP_ENV=local → DatabaseSeeder зовёт DemoSeeder (admin@demo.local) + session-cookie не secure-only.
|
||||
# SANCTUM_STATEFUL_DOMAINS обязан включать localhost:8000 — иначе сессия с Pa11y-хоста не залипает.
|
||||
# DB_SUPPLIER_* нужны: часть миграций пишет через pgsql_supplier-соединение (BYPASSRLS-роль).
|
||||
working-directory: app
|
||||
run: |
|
||||
touch database/database.sqlite
|
||||
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
|
||||
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
|
||||
setenv() { sed -i "/^$1=/d" .env; echo "$1=$2" >> .env; }
|
||||
setenv APP_ENV local
|
||||
setenv APP_DEBUG true
|
||||
setenv APP_URL http://localhost:8000
|
||||
setenv DB_CONNECTION pgsql
|
||||
setenv DB_HOST 127.0.0.1
|
||||
setenv DB_PORT 5432
|
||||
setenv DB_DATABASE liderra
|
||||
setenv DB_USERNAME postgres
|
||||
setenv DB_PASSWORD postgres
|
||||
setenv DB_SUPPLIER_USERNAME postgres
|
||||
setenv DB_SUPPLIER_PASSWORD postgres
|
||||
setenv DB_SSLMODE disable
|
||||
setenv SESSION_DRIVER file
|
||||
setenv CACHE_STORE file
|
||||
setenv QUEUE_CONNECTION sync
|
||||
setenv MAIL_MAILER log
|
||||
setenv SANCTUM_STATEFUL_DOMAINS localhost:8000,127.0.0.1:8000,localhost,127.0.0.1
|
||||
|
||||
- name: Prepare storage dirs (file session/cache need them)
|
||||
# SESSION_DRIVER=file пишет в storage/framework/sessions — без каталога 500 (урок PR #49).
|
||||
working-directory: app
|
||||
run: mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/logs bootstrap/cache
|
||||
|
||||
- name: Run migrations (postgres superuser → guarded SET ROLE works)
|
||||
working-directory: app
|
||||
run: php artisan migrate --force
|
||||
|
||||
- name: Create current-month partitions
|
||||
# schema.sql + миграции дают baseline-партиции; cron-команда докидывает текущий +2 месяца
|
||||
# (идемпотентно) — нужно для demo-сделок DemoSeeder'а за «сегодня».
|
||||
working-directory: app
|
||||
run: php artisan partitions:create-months --ahead=2
|
||||
|
||||
- name: Seed demo data (PricingTier + DemoSeeder admin@demo.local)
|
||||
working-directory: app
|
||||
run: php artisan db:seed --force
|
||||
|
||||
- name: Build frontend assets
|
||||
working-directory: app
|
||||
run: npm run build
|
||||
|
||||
- name: Start Laravel dev-server
|
||||
# PHP_CLI_SERVER_WORKERS>1: встроенный сервер обслуживает SPA + sub-resources параллельно.
|
||||
working-directory: app
|
||||
env:
|
||||
PHP_CLI_SERVER_WORKERS: 4
|
||||
run: nohup php artisan serve --host=127.0.0.1 --port=8000 > /tmp/laravel-serve.log 2>&1 &
|
||||
|
||||
- name: Wait for dev-server ready
|
||||
@@ -72,9 +149,14 @@ jobs:
|
||||
tail -50 /tmp/laravel-serve.log
|
||||
exit 1
|
||||
|
||||
- name: Run Pa11y (live Vue)
|
||||
- name: Run Pa11y (live Vue, 7 public + 14 authenticated routes)
|
||||
run: npm run a11y
|
||||
|
||||
- name: Laravel log tail on failure
|
||||
if: failure()
|
||||
working-directory: app
|
||||
run: tail -120 storage/logs/laravel.log || echo "no laravel.log"
|
||||
|
||||
- name: Upload Pa11y screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -53,6 +53,11 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
force:
|
||||
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
phone:
|
||||
description: 'smoke: телефон'
|
||||
required: false
|
||||
@@ -77,6 +82,7 @@ jobs:
|
||||
URL: ${{ github.event.inputs.url }}
|
||||
DIR: ${{ github.event.inputs.dir }}
|
||||
DRY: ${{ github.event.inputs.dry_run }}
|
||||
FORCE: ${{ github.event.inputs.force }}
|
||||
PHONE: ${{ github.event.inputs.phone }}
|
||||
|
||||
steps:
|
||||
@@ -344,12 +350,14 @@ jobs:
|
||||
run: |
|
||||
DRY_FLAG=""
|
||||
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
|
||||
FORCE_FLAG=""
|
||||
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; fi
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ==="
|
||||
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG 2>&1
|
||||
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
|
||||
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_FLAG 2>&1
|
||||
echo "=== Счётчики ==="
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
|
||||
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
|
||||
|
||||
-22
@@ -47,16 +47,6 @@ demo-*.jpeg
|
||||
# gitleaks
|
||||
gitleaks-report.json
|
||||
|
||||
# ward (security-сканер) — отчёты в корне
|
||||
ward-report.*
|
||||
lychee-links-report.txt
|
||||
walk-*.png
|
||||
|
||||
# ZAP active scan — сырые отчёты (анализ коммитится как .md, сырьё локально:
|
||||
# может содержать снимки ответов dev-приложения)
|
||||
docs/security/*-zap-active-scan.json
|
||||
docs/security/*-zap-active-scan.html
|
||||
|
||||
# ── IDE / редакторы ─────────────────────────────────────────────────────────
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -139,7 +129,6 @@ c--Users-*/
|
||||
# ── Временные файлы ─────────────────────────────────────────────────────────
|
||||
*.tmp
|
||||
*.bak
|
||||
.mcp.json.bak-*
|
||||
*.log
|
||||
tmp/
|
||||
.tmp/
|
||||
@@ -213,14 +202,3 @@ ruflo-mcp-stderr.log
|
||||
.claude/commands/*
|
||||
!.claude/commands/security-review.md
|
||||
.claude/helpers/
|
||||
|
||||
# ── Локальные бэкапы settings.json + эталон-снимки (M7 canon backups, local-only) ──
|
||||
.claude/arh settings/
|
||||
.claude/settings - *.json
|
||||
.claude/settings эталон*.json
|
||||
.claude/эталон/
|
||||
.claude/scheduled_tasks.lock
|
||||
/settings.json
|
||||
settings copy.json
|
||||
# Строчный Ctemp-дамп (CTemp* выше не ловит из-за регистра)
|
||||
Ctemp*
|
||||
|
||||
+1
-15
@@ -89,21 +89,10 @@ paths = [
|
||||
'''app/tests/.*\.php''',
|
||||
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
|
||||
'''app/database/seeders/.*\.php''',
|
||||
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
|
||||
# напр. TenantFactory::withRequisites +79150000000), не реальные ПДн. Та же
|
||||
# категория, что seeders/tests.
|
||||
'''app/database/factories/.*\.php''',
|
||||
# Audit-internal docs (findings/blocked/report/plan) — содержат демо-телефоны и
|
||||
# script-смешанные artifacts как finding'и для review (не реальные ПДн)
|
||||
'''docs/superpowers/audits/.*\.md''',
|
||||
'''docs/superpowers/plans/.*\.md''',
|
||||
# Приёмочные ранбуки (R0–R5) — синтетические тест-телефоны (79990001122 и
|
||||
# пр.) в матрицах провижининга/инъекции, не реальные ПДн. Та же категория,
|
||||
# что plans/specs/audits.
|
||||
'''docs/superpowers/runbooks/.*\.md''',
|
||||
# Internal design specs — внутренние проектные доки с демо-данными (демо-телефоны
|
||||
# в примерах, напр. spec про log-PII-scrubbing), не реальные ПДн. Как plans/audits.
|
||||
'''docs/superpowers/specs/.*\.md''',
|
||||
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
|
||||
'''app/resources/js/composables/mockDeals\.ts''',
|
||||
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
|
||||
@@ -112,10 +101,7 @@ paths = [
|
||||
'''app/resources/js/views/settings/.*\.vue''',
|
||||
# Test fixtures for the observer PII filter — contains synthetic JWT / AWS /
|
||||
# Yandex tokens that the filter is supposed to redact. Not real secrets.
|
||||
'''tools/observer-pii-filter\.test\.mjs''',
|
||||
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
|
||||
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
|
||||
'''tools/enforce-read-path-deny\.test\.mjs'''
|
||||
'''tools/observer-pii-filter\.test\.mjs'''
|
||||
]
|
||||
regexTarget = "match"
|
||||
regexes = [
|
||||
|
||||
+2
-3
@@ -54,9 +54,8 @@ exclude = [
|
||||
# Sample/примерные адреса
|
||||
"^https?://example\\.com",
|
||||
"^https?://example\\.org",
|
||||
# Покойный GitHub-аккаунт CoralMinister (suspended) — все ссылки на него мертвы:
|
||||
# исторические compare/actions-runs в ПИЛОТ.md / handoffs / plans. Бэкап теперь Gitea.
|
||||
"^https?://github\\.com/CoralMinister/",
|
||||
# Приватный репозиторий проекта (404 для анонимных запросов — это норма)
|
||||
"^https?://github\\.com/CoralMinister/liderra",
|
||||
# web/v8/*.html — статические концепты, root-relative ссылки на будущие маршруты Vue
|
||||
"^/(login|register|legal|dashboard|deals|admin|reports|reminders|billing|impersonation|notifications)(/|$|\\?)",
|
||||
# Корневой `/` в концептах (логотип-якорь для будущей главной)
|
||||
|
||||
@@ -6,4 +6,3 @@ CLAUDE.md
|
||||
.claude/skills/ccpm/
|
||||
.claude/skills/data-scientist/
|
||||
.claude/skills/marketingskills/
|
||||
docs/superpowers/
|
||||
|
||||
@@ -54,31 +54,6 @@
|
||||
},
|
||||
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
|
||||
},
|
||||
"perplexity": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@perplexity-ai/mcp-server"],
|
||||
"env": {
|
||||
"PERPLEXITY_API_KEY": "${PERPLEXITY_API_KEY}",
|
||||
"PERPLEXITY_BASE_URL": "https://api.aitunnel.ru/v1"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #87 — research-канал. Официальный @perplexity-ai/mcp-server (репо perplexityai/modelcontextprotocol), MIT, подписанная сборка. Tools: perplexity_search/ask/research/reason (sonar-*). ПЛАТНЫЙ API; ключ PERPLEXITY_API_KEY только в user env (не в репо). Вет ПРИНЯТ — docs/research/research-vet.md. Перенос plan-v13 2026-06-14 (owner waiver, Вариант 2)."
|
||||
},
|
||||
"exa": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "exa-mcp-server"],
|
||||
"env": {
|
||||
"EXA_API_KEY": "${EXA_API_KEY}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #88 — Exa нейро/семантический поиск. exa-mcp-server (репо exa-labs), MIT (license-поле npm пусто — см. вет). Tools: web_search_exa / web_fetch_exa (default). ПЛАТНЫЙ API; ключ EXA_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
|
||||
},
|
||||
"firecrawl": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"env": {
|
||||
"FIRECRAWL_API_KEY": "${FIRECRAWL_API_KEY}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #89 — Firecrawl глубокое чтение/обход. firecrawl-mcp (репо firecrawl/firecrawl-mcp-server), MIT, очень активен. Tools: scrape/crawl/extract + firecrawl_agent. ПЛАТНЫЙ API; ключ FIRECRAWL_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
|
||||
},
|
||||
"_disabled_marketing_servers_note": "ОТКЛЮЧЕНЫ 2026-05-31 (владелец: «отрежь маркетинг»). Причина: их авто-генерируемые схемы (особенно wordstat — 128 tools из Яндекс.Директа) — главный подозреваемый в API 400 tools.110/113, ронявшем субагентов при bulk-load всех инструментов (subagent-driven-development). Серверы off-phase и без OAuth-токенов всё равно не стартовали. Полный конфиг — в git до этого коммита. Чтобы вернуть, восстановить три блока mcpServers: marketing-metrika (npx -y github:atomkraft/yandex-metrika-mcp; env YANDEX_OAUTH_TOKEN; READ-ONLY; Tooling §4.53), marketing-wordstat (npx -y github:SvechaPVL/yandex-mcp; env YANDEX_OAUTH_TOKEN; ТОЛЬКО Wordstat per IS9/MKT8; Tooling §4.54), marketing-telegram (npx -y github:chigwell/telegram-mcp; env TELEGRAM_API_ID/API_HASH/SESSION_STRING; выделенный аккаунт IS9; Tooling §4.51). См. docs/security/marketing-vet.md и docs/marketing/README.md.",
|
||||
"_comment_postiz_skeleton": "TODO: C1 marketing-tooling #81 — Postiz MCP (gitroomhq/postiz-app self-host + antoniolg/postiz-mcp). Активировать ПОСЛЕ: 1) развернуть Postiz self-hosted (git clone https://github.com/gitroomhq/postiz-app + docker-compose, AGPL-3.0: internal-only, no modifications); 2) провести vet лицензии antoniolg/postiz-mcp (NOT YET VERIFIED — см. docs/marketing/README.md Open vet notes); 3) подключить соцсети в Postiz UI. Будущий entry: \"marketing-postiz\": { \"command\": \"npx\", \"args\": [\"-y\", \"postiz-mcp\"], \"env\": { \"POSTIZ_API_URL\": \"${POSTIZ_API_URL}\", \"POSTIZ_API_KEY\": \"${POSTIZ_API_KEY}\" }, \"comment\": \"C1 #81 post-activation\" }. Tooling §4.52. docs/marketing/README.md."
|
||||
}
|
||||
|
||||
@@ -42,18 +42,6 @@ SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru
|
||||
# Supplier alerts (email через Unisender Go relay)
|
||||
SUPPLIER_ALERT_EMAIL=
|
||||
|
||||
# SaaS-admin fail-closed гейт (M-1). Логины nginx basic-auth (.htpasswd-admin),
|
||||
# допущенные в /api/admin/*. CSV; дефолт совпадает с прод-.htpasswd.
|
||||
ADMIN_ALLOWED_USERS=admin
|
||||
# ADMIN_GATE_ENFORCED=true # авто: true вне local/testing; задать явно для override
|
||||
# Системный admin-id для audit-trail (FK saas_admin_audit_log). На проде crm_app_user
|
||||
# не имеет прав на saas_admin_users → задать id сид-стаба. dev/test — оставить пустым.
|
||||
ADMIN_AUDIT_SYSTEM_USER_ID=
|
||||
|
||||
# Капча самозаписи (M-2). driver=null (dev) | yandex (prod). Для yandex нужен server-key.
|
||||
CAPTCHA_DRIVER=null
|
||||
YANDEX_SMARTCAPTCHA_SERVER_KEY=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
@@ -82,8 +70,6 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
SUPPORT_EMAIL=support@liderra.app
|
||||
JIVO_WIDGET_ID=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
@@ -92,7 +78,3 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
|
||||
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
|
||||
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.env.testing
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.deptrac.cache
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Imitation;
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\RussianRegions;
|
||||
use Carbon\Carbon;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Populate a LOCAL portal with imitation clients and leads for hands-on UI review
|
||||
* (Phase 1 imitation harness). It NEVER runs on production.
|
||||
*
|
||||
* Self-contained on purpose (it must not depend on test-harness helpers): it funds
|
||||
* a few tenant balances locally, disables the external DaData call (region is taken
|
||||
* from the lead tag), builds the routing snapshot for the active date, then injects
|
||||
* synthetic leads through the real RouteSupplierLeadJob so deals, charges and
|
||||
* notifications appear exactly as they would in production.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
|
||||
*/
|
||||
final class ImitationSeedCommand extends Command
|
||||
{
|
||||
protected $signature = 'imitation:seed
|
||||
{--leads=20 : Number of synthetic leads to inject}
|
||||
{--clients=3 : Number of imitation clients to create}';
|
||||
|
||||
protected $description = 'Populate the LOCAL portal with imitation clients and leads for UI review (never on production)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->getLaravel()->environment('production')) {
|
||||
$this->error('imitation:seed is forbidden in production.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$leads = max(1, (int) $this->option('leads'));
|
||||
$clients = max(1, (int) $this->option('clients'));
|
||||
|
||||
// Region comes from the lead tag — no external (paid) DaData call.
|
||||
config(['services.dadata.enabled' => false]);
|
||||
|
||||
// Reference data required by the ledger.
|
||||
(new PricingTierSeeder)->run();
|
||||
|
||||
$moscow = RussianRegions::nameToCode()['Москва']; // ordinal 82
|
||||
|
||||
// One shared supplier source (B2 site signal). The unique_key must be a
|
||||
// domain-like string: RouteSupplierLeadJob re-resolves the supplier from the
|
||||
// lead payload by (platform, unique_key) and infers signal_type from the
|
||||
// identifier shape (see parseProjectField/resolveOrStub) — a domain → 'site'.
|
||||
$supplierKey = 'imitseed-'.strtolower(Str::random(8)).'.test';
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => $supplierKey,
|
||||
]);
|
||||
|
||||
// Funded imitation clients, all targeting Москва, full week, generous limit.
|
||||
for ($i = 1; $i <= $clients; $i++) {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$project = Project::factory()
|
||||
->asSiteSignal('imitseed-'.$i.'-'.Str::random(6).'.test')
|
||||
->create([
|
||||
'name' => "IMIT-seed-client-{$i}",
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [$moscow],
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 1000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Build the routing snapshot for the active date the router will query.
|
||||
Artisan::call('snapshot:rebuild', ['--date' => $this->activeDate()]);
|
||||
|
||||
// Inject synthetic leads through the real routing + ledger pipeline.
|
||||
$injected = 0;
|
||||
for ($n = 1; $n <= $leads; $n++) {
|
||||
$phone = '79'.str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
$vid = random_int(100_000_000, 999_999_999);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
'phone' => $phone,
|
||||
'vid' => $vid,
|
||||
'raw_payload' => [
|
||||
'vid' => $vid,
|
||||
'project' => $supplier->platform.'_'.$supplierKey,
|
||||
'tag' => 'Москва',
|
||||
'phone' => $phone,
|
||||
'phones' => [$phone],
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
'deals_created_count' => null,
|
||||
]);
|
||||
|
||||
RouteSupplierLeadJob::dispatchSync($lead->id);
|
||||
$injected++;
|
||||
}
|
||||
|
||||
$this->info("imitation:seed done — {$clients} clients, {$injected} leads injected (region from tag, DaData disabled).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active snapshot date — mirrors LeadRouter::activeSnapshotDate()
|
||||
* (today before 21:00 MSK, tomorrow at/after).
|
||||
*/
|
||||
private function activeDate(): string
|
||||
{
|
||||
$msk = Carbon::now('Europe/Moscow');
|
||||
|
||||
return ($msk->hour >= 21 ? $msk->copy()->addDay() : $msk)->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Pd;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* F-P1 / 152-ФЗ ретеншен: анонимизирует ПДн soft-deleted сделок по истечении
|
||||
* настраиваемого срока (спека 2026-06-17-fp1-deal-pii-retention-spec).
|
||||
*
|
||||
* Срок (дней) — в system_settings, ключ `pd_scrub_soft_deleted_deals_days`.
|
||||
* Отсутствие ключа или значение < 1 → no-op (юридический срок не зашит в код,
|
||||
* выставляется на проде). Паттерн безопасности идентичен PartitionsDropExpired.
|
||||
*
|
||||
* Значения анонимизации идентичны PdErasureService::eraseSubject. Работает
|
||||
* cross-tenant через pgsql_supplier (BYPASSRLS). Идемпотентно: уже затёртые
|
||||
* (phone = ANON_PHONE) исключаются из выборки.
|
||||
*/
|
||||
class ScrubSoftDeletedDealsCommand extends Command
|
||||
{
|
||||
private const DB = 'pgsql_supplier';
|
||||
|
||||
private const SETTING_KEY = 'pd_scrub_soft_deleted_deals_days';
|
||||
|
||||
private const ANON_PHONE = '+7000XXXXXXX';
|
||||
|
||||
private const ANON_NAME = 'Удалено';
|
||||
|
||||
/** @var string */
|
||||
protected $signature = 'pd:scrub-soft-deleted-deals
|
||||
{--dry-run : Показать число кандидатов, не анонимизировать}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Анонимизирует ПДн (телефон/имя) soft-deleted сделок старше retention-срока (152-ФЗ, F-P1)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = $this->resolveRetentionDays();
|
||||
|
||||
if ($days === null) {
|
||||
$this->line('<fg=gray>skip</> retention не настроен (system_settings.'.self::SETTING_KEY.' отсутствует или < 1).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$cutoff = CarbonImmutable::now()->subDays($days);
|
||||
$candidates = $this->candidateQuery($cutoff)->count();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("<fg=yellow>[dry-run]</> кандидатов на анонимизацию: {$candidates} (deleted_at старше {$days} дн.)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($candidates === 0) {
|
||||
$this->info("Кандидатов на анонимизацию нет (retention={$days} дн.).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
// Bulk-UPDATE атомарен одним SQL; лог — одна summary-запись. Явная
|
||||
// транзакция не нужна и несовместима с shared-PDO в тестах
|
||||
// (pgsql_supplier делит сессию с уже открытой транзакцией pgsql).
|
||||
$this->candidateQuery($cutoff)->update([
|
||||
'phone' => self::ANON_PHONE,
|
||||
'contact_name' => self::ANON_NAME,
|
||||
'phones' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Системное действие: оба actor-поля NULL (допускается chk_pd_actor).
|
||||
// log_hash заполняется триггером цепочки целостности.
|
||||
DB::connection(self::DB)->table('pd_processing_log')->insert([
|
||||
'tenant_id' => null,
|
||||
'subject_type' => 'deal',
|
||||
'subject_id' => null,
|
||||
'action' => 'deleted',
|
||||
'purpose' => '152-FZ retention scrub',
|
||||
'actor_tenant_user_id' => null,
|
||||
'actor_admin_user_id' => null,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$this->info("Анонимизировано сделок: {$candidates} (retention={$days} дн.).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** Кандидаты: soft-deleted старше cutoff, ещё не анонимизированные. */
|
||||
private function candidateQuery(CarbonImmutable $cutoff): Builder
|
||||
{
|
||||
return DB::connection(self::DB)->table('deals')
|
||||
->whereNotNull('deleted_at')
|
||||
->where('deleted_at', '<', $cutoff)
|
||||
->where('phone', '<>', self::ANON_PHONE);
|
||||
}
|
||||
|
||||
/** Срок ретеншена из system_settings; null если ключа нет или значение < 1. */
|
||||
private function resolveRetentionDays(): ?int
|
||||
{
|
||||
$setting = SystemSetting::find(self::SETTING_KEY);
|
||||
|
||||
if ($setting === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (int) $setting->value;
|
||||
|
||||
return $value >= 1 ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
|
||||
|
||||
@@ -136,7 +135,7 @@ class PhoneRangesImportCommand extends Command
|
||||
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info('dry-run: '.count($rows).' строк в phone_ranges_staging, swap не выполнен.');
|
||||
$this->info('dry-run: '.count($rows)." строк в phone_ranges_staging, swap не выполнен.");
|
||||
if ($unmatchedNote !== '') {
|
||||
$this->warn($unmatchedNote);
|
||||
}
|
||||
@@ -172,7 +171,7 @@ class PhoneRangesImportCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null Список файлов или null при ошибке валидации опций.
|
||||
* @return list<string>|null Список файлов или null при ошибке валидации опций.
|
||||
*/
|
||||
private function resolveFiles(): ?array
|
||||
{
|
||||
@@ -295,7 +294,7 @@ class PhoneRangesImportCommand extends Command
|
||||
*/
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$reader = new XlsxReader;
|
||||
$reader = new XlsxReader();
|
||||
$reader->open($path);
|
||||
|
||||
$out = [];
|
||||
@@ -431,7 +430,7 @@ class PhoneRangesImportCommand extends Command
|
||||
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
|
||||
* отсутствует → RESET и работаем как superuser (зеркало миграционного паттерна).
|
||||
*/
|
||||
private function elevate(Connection $c): void
|
||||
private function elevate(\Illuminate\Database\Connection $c): void
|
||||
{
|
||||
try {
|
||||
$c->statement('SET ROLE crm_migrator');
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Reminder;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Cron-команда диспатча due-reminders.
|
||||
*
|
||||
* Идёт по `reminders` где `is_sent=false AND completed_at IS NULL AND
|
||||
* remind_at <= NOW()`. Для каждой строки:
|
||||
* 1) NotificationService::notifyReminder (email + inapp по prefs);
|
||||
* 2) UPDATE is_sent=true, sent_at=NOW().
|
||||
*
|
||||
* RLS: SET LOCAL app.current_tenant_id = reminder.tenant_id внутри
|
||||
* транзакции каждой обработки (по одному reminder в транзакции — иначе
|
||||
* нельзя переключить tenant между строками с разных tenant'ов).
|
||||
*
|
||||
* Запускается каждую минуту через Windows Task Scheduler / cron.
|
||||
* Идемпотентна: повторный вызов на отправленных ($is_sent=true) skipаются.
|
||||
*
|
||||
* --dry-run печатает плановых получателей без реальных INSERT'ов.
|
||||
*
|
||||
* Источник: db/schema.sql §17.5; ТЗ §6.6 / §18.5.
|
||||
*/
|
||||
class RemindersDispatchDue extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'reminders:dispatch-due
|
||||
{--dry-run : Не отправлять, только напечатать список плановых получателей}
|
||||
{--limit=500 : Максимум reminders за один запуск}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Диспатч due-reminders: email/inapp уведомления + is_sent=true (ТЗ §18.5)';
|
||||
|
||||
public function handle(NotificationService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$now = Carbon::now();
|
||||
|
||||
// Cross-tenant gather via BYPASSRLS connection — on prod crm_app_user cannot
|
||||
// call current_setting('app.current_tenant_id') without a GUC set first.
|
||||
// pgsql_supplier (crm_supplier_worker, BYPASSRLS) is the canonical pattern
|
||||
// for SaaS-admin cron queries (precedent: IncidentsWatchFailures, Reset*).
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('reminders')
|
||||
->select(['id', 'tenant_id', 'deal_id', 'remind_at'])
|
||||
->where('is_sent', false)
|
||||
->whereNull('completed_at')
|
||||
->where('remind_at', '<=', $now)
|
||||
->orderBy('remind_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Нет due-reminders.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
|
||||
$row->id,
|
||||
$row->tenant_id,
|
||||
$row->deal_id,
|
||||
$row->remind_at ?? '-',
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($row, $service): void {
|
||||
// SET LOCAL scopes GUC to this transaction — PgBouncer-safe.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $row->tenant_id);
|
||||
// Fetch the full Eloquent model with tenant context active so
|
||||
// relations (user, etc.) work correctly inside NotificationService.
|
||||
$reminder = Reminder::query()->findOrFail((int) $row->id);
|
||||
$service->notifyReminder($reminder);
|
||||
$reminder->update([
|
||||
'is_sent' => true,
|
||||
'sent_at' => Carbon::now(),
|
||||
]);
|
||||
});
|
||||
$sent++;
|
||||
$this->info(" dispatched <fg=green>id={$row->id}</>");
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(" failed <fg=red>id={$row->id}</>: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done: {$sent} sent, {$failed} failed (limit={$limit}, dry-run=".($dryRun ? '1' : '0').').');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ final class SnapshotBackfillCommand extends Command
|
||||
$weekdayBit = 1 << ($date->isoWeekday() - 1);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
|
||||
return DB::connection('pgsql_supplier')->insert(<<<'SQL'
|
||||
return DB::connection('pgsql_supplier')->insert(<<<SQL
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
|
||||
@@ -47,7 +47,7 @@ final class SnapshotRebuildCommand extends Command
|
||||
->where('snapshot_date', $dateStr)
|
||||
->delete();
|
||||
|
||||
$inserted = DB::connection('pgsql_supplier')->insert(<<<'SQL'
|
||||
$inserted = DB::connection('pgsql_supplier')->insert(<<<SQL
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Account\ChangePasswordRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Аккаунт пользователя — вкладка «Безопасность» (UI-аудит 21.06.2026).
|
||||
*
|
||||
* Заменяет статичные mock-карточки (ChangePasswordCard/SessionsTable):
|
||||
* - POST /api/account/change-password — реальная смена пароля.
|
||||
* - GET /api/account/security — дата последней смены пароля + активные сессии.
|
||||
* - DELETE /api/account/sessions/{id} — отозвать сессию (UI-аудит 21.06.2026).
|
||||
*
|
||||
* Активные сессии берутся из user_sessions (запись при входе); отзыв реально
|
||||
* убивает сессию (удаление из Redis по session_id). Заменяет прежний mock.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
/**
|
||||
* POST /api/account/change-password — смена пароля авторизованным пользователем.
|
||||
*
|
||||
* Проверяет текущий пароль (Hash::check против password_hash), пишет новый хэш,
|
||||
* логирует password_changed в auth_log. На неверном текущем — 422 + лог
|
||||
* password_change_failed.
|
||||
*/
|
||||
public function changePassword(ChangePasswordRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (! Hash::check($request->string('current_password')->toString(), (string) $user->password_hash)) {
|
||||
$this->logAuthEvent(
|
||||
'password_change_failed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
'wrong_current_password',
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'current_password' => ['Неверный текущий пароль.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
])->save();
|
||||
|
||||
$this->logAuthEvent(
|
||||
'password_changed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Пароль изменён.',
|
||||
'last_password_change_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/security — данные вкладки «Безопасность».
|
||||
*
|
||||
* last_password_change_at — max(created_at) по password-событиям в auth_log
|
||||
* (null, если пароль ни разу не менялся через портал).
|
||||
* recent_logins — последние входы текущего пользователя (устройство/IP/время).
|
||||
*/
|
||||
public function security(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
$lastChange = DB::table('auth_log')
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('event', ['password_changed', 'password_reset_completed'])
|
||||
->max('created_at');
|
||||
|
||||
$currentSid = $request->session()->getId();
|
||||
$rows = DB::table('user_sessions')
|
||||
->where('user_id', $user->id)
|
||||
->where('expires_at', '>', now())
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get(['id', 'token_hash', 'ip_address', 'user_agent', 'last_active_at', 'created_at']);
|
||||
|
||||
$sessions = $rows->map(fn ($row): array => [
|
||||
'id' => $row->id,
|
||||
'device' => $this->deviceLabel($row->user_agent),
|
||||
'ip' => $row->ip_address,
|
||||
'at' => Carbon::parse($row->last_active_at ?? $row->created_at)->toIso8601String(),
|
||||
'current' => $row->token_hash === $currentSid,
|
||||
])->all();
|
||||
|
||||
return response()->json([
|
||||
'last_password_change_at' => $lastChange ? Carbon::parse($lastChange)->toIso8601String() : null,
|
||||
'sessions' => $sessions,
|
||||
]);
|
||||
}
|
||||
|
||||
/** DELETE /api/account/sessions/{id} — отозвать конкретную сессию пользователя. */
|
||||
public function revokeSession(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$ok = app(UserSessionTracker::class)->revoke($user->id, $id);
|
||||
|
||||
if (! $ok) {
|
||||
return response()->json(['message' => 'Сессия не найдена.'], 404);
|
||||
}
|
||||
|
||||
$this->logAuthEvent(
|
||||
'session_revoked',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Сессия завершена.']);
|
||||
}
|
||||
|
||||
/** Грубый человекочитаемый ярлык устройства из User-Agent (браузер + ОС). */
|
||||
private function deviceLabel(?string $ua): string
|
||||
{
|
||||
if ($ua === null || $ua === '') {
|
||||
return 'Неизвестное устройство';
|
||||
}
|
||||
|
||||
$browser = match (true) {
|
||||
str_contains($ua, 'Firefox/') => 'Firefox',
|
||||
str_contains($ua, 'Edg/') => 'Edge',
|
||||
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
|
||||
str_contains($ua, 'Chrome/') => 'Chrome',
|
||||
str_contains($ua, 'Safari/') => 'Safari',
|
||||
default => 'Браузер',
|
||||
};
|
||||
|
||||
$os = match (true) {
|
||||
str_contains($ua, 'Windows') => 'Windows',
|
||||
str_contains($ua, 'Android') => 'Android',
|
||||
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
|
||||
str_contains($ua, 'Mac OS') || str_contains($ua, 'Macintosh') => 'macOS',
|
||||
str_contains($ua, 'Linux') => 'Linux',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return $os !== '' ? "{$browser}, {$os}" : $browser;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ final class AdminPricingTiersController extends Controller
|
||||
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
|
||||
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
|
||||
'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after_or_equal:'.$todayMsk],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
|
||||
]);
|
||||
|
||||
/** @var array<int, array{tier_no:int, leads_in_tier:?int, price_rub:string|float}> $tiers */
|
||||
@@ -163,13 +163,6 @@ final class AdminPricingTiersController extends Controller
|
||||
*/
|
||||
private function resolveAdminUserId(Request $request): int
|
||||
{
|
||||
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
|
||||
// admin-id из конфига, не обращаясь к таблице. null (dev/test) → fallback ниже.
|
||||
$configured = config('admin.audit_system_user_id');
|
||||
if ($configured !== null) {
|
||||
return (int) $configured;
|
||||
}
|
||||
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
|
||||
@@ -7,12 +7,11 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Mail\SuspiciousLoginNotification;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -125,7 +124,6 @@ class AuthController extends Controller
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
@@ -133,25 +131,46 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
{
|
||||
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
|
||||
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
|
||||
$tenant = Tenant::first();
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'message' => 'Tenants не настроены. Обратитесь к администратору.',
|
||||
], 503);
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $request->string('email')->toString(),
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
'first_name' => 'Новый',
|
||||
'last_name' => 'Пользователь',
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$resource = $this->userResource($user);
|
||||
|
||||
$marker = $request->hasSession() ? $request->session()->get('impersonation') : null;
|
||||
if ($marker !== null) {
|
||||
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id']);
|
||||
$tenant = $token?->tenant;
|
||||
$resource['impersonation'] = [
|
||||
'active' => true,
|
||||
'tenant_name' => $tenant?->organization_name,
|
||||
'started_at' => $marker['started_at'] ?? null,
|
||||
'expires_at' => $token?->sessionExpiresAt()?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json(['user' => $resource]);
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
@@ -160,9 +179,6 @@ class AuthController extends Controller
|
||||
$tenantId = $request->user()?->tenant_id;
|
||||
$email = $request->user()?->email;
|
||||
|
||||
// Снять текущую сессию из списка «Активные» до инвалидации (id ещё прежний).
|
||||
app(UserSessionTracker::class)->revokeCurrent($request);
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
@@ -13,7 +13,6 @@ use App\Models\User;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -317,8 +316,21 @@ class BillingController extends Controller
|
||||
*/
|
||||
private function runwayDays(Tenant $tenant, int $affordableLeads): ?int
|
||||
{
|
||||
// F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом),
|
||||
// чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом.
|
||||
return app(RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads);
|
||||
if ($affordableLeads <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$leadsLast30Days = (int) DB::table('lead_charges')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('charged_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
if ($leadsLast30Days <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$avgPerDay = $leadsLast30Days / 30.0;
|
||||
|
||||
return max(0, (int) floor($affordableLeads / $avgPerDay));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -107,32 +103,13 @@ class DashboardController extends Controller
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway (F3, 17.06.2026: единый источник с биллингом) ---
|
||||
// Раньше дашборд считал от legacy `balance_leads` (после Billing v2 ≈0
|
||||
// для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней».
|
||||
// Теперь — affordable leads от рублёвого баланса по тарифу
|
||||
// (BalanceToLeadsConverter) + общий RunwayCalculator.
|
||||
$activeTiers = app(PricingTierRepository::class)
|
||||
->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$conversion = app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
(int) ($tenant->delivered_in_month ?? 0),
|
||||
$activeTiers,
|
||||
);
|
||||
$affordableLeads = (int) $conversion['leads'];
|
||||
$runwayDays = app(RunwayCalculator::class)
|
||||
->daysLeft($tenantId, $affordableLeads) ?? 0;
|
||||
|
||||
// --- средняя стоимость лида (F5): среднее фактически списанных rub-сумм
|
||||
// за окно периода. Только charge_source='rub' (у prepaid цена 0 по CHECK —
|
||||
// иначе среднее занижается); источник тот же, что у карточки сделки (F2).
|
||||
// null, если в окне нет rub-списаний (ничего ещё не списано).
|
||||
$avgKopecks = DB::table('lead_charges')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('charge_source', 'rub')
|
||||
->whereBetween('charged_at', [$windowStart, $now])
|
||||
->avg('price_per_lead_kopecks');
|
||||
$avgLeadCostRub = $avgKopecks !== null ? round((float) $avgKopecks / 100, 2) : null;
|
||||
// --- runway ---
|
||||
// runway опирается на приток за фиксированное 7-дневное окно,
|
||||
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
|
||||
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
|
||||
$avgDaily = $leads7d / 7.0;
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
@@ -142,11 +119,10 @@ class DashboardController extends Controller
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $affordableLeads,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
'avg_lead_cost_rub' => $avgLeadCostRub,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\User;
|
||||
@@ -103,6 +102,13 @@ class DealController extends Controller
|
||||
// whereNotNull('deleted_at') фильтрует только удалённые.
|
||||
$query = Deal::query()
|
||||
->select('deals.*')
|
||||
->addSelect(['next_reminder_at' => DB::table('reminders')
|
||||
->select('remind_at')
|
||||
->whereColumn('reminders.deal_id', 'deals.id')
|
||||
->whereNull('reminders.completed_at')
|
||||
->orderBy('remind_at')
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
@@ -211,6 +217,9 @@ class DealController extends Controller
|
||||
'project_signal_identifier' => $d->project?->signal_identifier,
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
'next_reminder_at' => $d->next_reminder_at
|
||||
? Carbon::parse($d->next_reminder_at)->toIso8601String()
|
||||
: null,
|
||||
]),
|
||||
'limit' => $limit,
|
||||
'next_cursor' => $nextCursor,
|
||||
@@ -237,7 +246,7 @@ class DealController extends Controller
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
[$deal, $events, $charge] = DB::transaction(function () use ($tenantId, $id) {
|
||||
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$deal = Deal::query()
|
||||
@@ -247,7 +256,7 @@ class DealController extends Controller
|
||||
->first();
|
||||
|
||||
if ($deal === null) {
|
||||
return [null, [], null];
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$events = ActivityLog::query()
|
||||
@@ -259,14 +268,7 @@ class DealController extends Controller
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
// F2: реальная стоимость лида — снимок списания из lead_charges
|
||||
// (rub-провенанс). Запрос в транзакции, где выставлен app.current_tenant_id.
|
||||
$charge = LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('deal_id', $id)
|
||||
->first();
|
||||
|
||||
return [$deal, $events, $charge];
|
||||
return [$deal, $events];
|
||||
});
|
||||
|
||||
if ($deal === null) {
|
||||
@@ -307,8 +309,6 @@ class DealController extends Controller
|
||||
'project_signal_identifier' => $deal->project?->signal_identifier,
|
||||
'project_sms_keyword' => $deal->project?->sms_keyword,
|
||||
'project_sms_senders' => $deal->project?->sms_senders,
|
||||
// F2: стоимость лида = снимок rub-списания (копейки) или null (prepaid/не списано).
|
||||
'cost_kopecks' => ($charge && $charge->charge_source === 'rub') ? $charge->price_per_lead_kopecks : null,
|
||||
],
|
||||
'events' => $events->map(fn (ActivityLog $e) => [
|
||||
'id' => $e->id,
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Support\CsvFormulaGuard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -123,15 +122,12 @@ class DealExportController extends Controller
|
||||
$signal = $deal->project?->signal_type;
|
||||
$source = trim(($deal->project?->name ?? '—').' · '
|
||||
.(self::SIGNAL_LABELS[$signal] ?? '—'));
|
||||
// F-CSV: свободный текст (телефон/источник/город/статус/
|
||||
// комментарий) экранируем от formula-инъекции. Дата —
|
||||
// системная, не экранируется.
|
||||
$writer->addRow(Row::fromValues([
|
||||
CsvFormulaGuard::neutralize((string) $deal->phone),
|
||||
CsvFormulaGuard::neutralize($source),
|
||||
CsvFormulaGuard::neutralize((string) ($deal->city ?? '')),
|
||||
CsvFormulaGuard::neutralize((string) ($statusNames[$deal->status] ?? $deal->status)),
|
||||
CsvFormulaGuard::neutralize((string) ($deal->comment ?? '')),
|
||||
(string) $deal->phone,
|
||||
$source,
|
||||
(string) ($deal->city ?? ''),
|
||||
(string) ($statusNames[$deal->status] ?? $deal->status),
|
||||
(string) ($deal->comment ?? ''),
|
||||
$deal->received_at?->toDateTimeString() ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -5,19 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ImpersonationCodeMail;
|
||||
use App\Mail\ImpersonationEndedMail;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\ImpersonationAuditService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* SaaS-admin impersonation flow (ТЗ §22.7 / Ю-1).
|
||||
@@ -46,8 +39,6 @@ class ImpersonationController extends Controller
|
||||
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
private const SESSION_TTL_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* SaaS-admin — кросс-тенантная зона: запросы к impersonation_tokens / tenants
|
||||
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
|
||||
@@ -143,12 +134,7 @@ class ImpersonationController extends Controller
|
||||
|
||||
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
|
||||
|
||||
try {
|
||||
Mail::to((string) $tenant->contact_email)
|
||||
->queue(new ImpersonationCodeMail($plainCode, (string) $tenant->contact_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation init: не удалось поставить письмо с кодом: '.$e->getMessage());
|
||||
}
|
||||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||||
$payload = [
|
||||
'token_id' => $token->id,
|
||||
'expires_at' => $token->expires_at->toIso8601String(),
|
||||
@@ -204,33 +190,10 @@ class ImpersonationController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Success: целевой пользователь тенанта = самый ранний активный.
|
||||
$targetUser = User::on(self::DB_CONNECTION)
|
||||
->where('tenant_id', $token->tenant_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($targetUser === null) {
|
||||
return response()->json(['message' => 'У тенанта нет активного пользователя для входа.'], 422);
|
||||
}
|
||||
|
||||
// Машинный ключ для ИИ: lpimp_<id>_<secret>. Храним только хеш секрета.
|
||||
$secret = Str::random(48);
|
||||
$machineToken = 'lpimp_'.$token->id.'_'.$secret;
|
||||
|
||||
// Success: mark used. Создание saas_admin_session с
|
||||
// impersonating_token_id — отдельный коммит после saas-admin auth.
|
||||
$token->update([
|
||||
'used_at' => now(),
|
||||
'session_token_hash' => Hash::make($secret),
|
||||
]);
|
||||
|
||||
// Путь человека: логиним браузер целевым пользователем + маркер impersonation в сессию.
|
||||
Auth::login($targetUser);
|
||||
$request->session()->put('impersonation', [
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
'target_user_id' => $targetUser->id,
|
||||
'started_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
@@ -239,8 +202,6 @@ class ImpersonationController extends Controller
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
'used_at' => $token->used_at->toIso8601String(),
|
||||
'expires_at' => $token->sessionExpiresAt(self::SESSION_TTL_MINUTES)->toIso8601String(),
|
||||
'machine_token' => $machineToken,
|
||||
'message' => 'Impersonation начат. Сессия активна 1 час.',
|
||||
]);
|
||||
}
|
||||
@@ -271,12 +232,7 @@ class ImpersonationController extends Controller
|
||||
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
|
||||
try {
|
||||
Mail::to((string) $token->sent_to_email)
|
||||
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation end mail: '.$e->getMessage());
|
||||
}
|
||||
// TODO: уведомление клиенту по email о завершении (как и в init flow).
|
||||
|
||||
return response()->json([
|
||||
'token_id' => $token->id,
|
||||
@@ -284,35 +240,4 @@ class ImpersonationController extends Controller
|
||||
'message' => 'Impersonation завершён.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/impersonation/leave — завершить свою impersonation-сессию из кабинета.
|
||||
*
|
||||
* Маркер `impersonation` из сессии НЕ удаляется здесь намеренно:
|
||||
* ImpersonationContext (global web middleware) на следующем запросе
|
||||
* обнаружит isSessionActive()=false и вернёт 401 явно, не доходя до auth:sanctum.
|
||||
* Это обеспечивает корректный 401 как в реальном браузере, так и в тест-среде
|
||||
* (где Auth::guard('web')->logout() может не повлиять на кэш sanctum-guard).
|
||||
*/
|
||||
public function leave(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$marker = $request->session()->get('impersonation');
|
||||
if ($marker === null) {
|
||||
return response()->json(['message' => 'Сессия impersonation не активна.'], 422);
|
||||
}
|
||||
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($marker['token_id']);
|
||||
if ($token !== null && $token->session_ended_at === null) {
|
||||
$token->update(['session_ended_at' => now()]);
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
try {
|
||||
Mail::to((string) $token->sent_to_email)
|
||||
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation leave mail: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Вы вышли из режима поддержки.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Project\ProjectService;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -30,10 +29,7 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectService $projects,
|
||||
private readonly RequisitesService $requisites,
|
||||
) {}
|
||||
public function __construct(private readonly ProjectService $projects) {}
|
||||
|
||||
/** GET /api/projects */
|
||||
public function index(Request $request): JsonResponse
|
||||
@@ -126,13 +122,6 @@ class ProjectController extends Controller
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
|
||||
// G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов.
|
||||
if (Project::where('tenant_id', $tenant->id)->count() === 0
|
||||
&& ! $this->requisites->isLightComplete($tenant)) {
|
||||
return response()->json(['error' => 'requisites_required'], 422);
|
||||
}
|
||||
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ConfirmEmailRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\ResendCodeRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\RegistrationException;
|
||||
use App\Services\Auth\RegistrationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Самозапись клиента (G1/SP1): register → confirm-email → (вход).
|
||||
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
|
||||
* pending_email_confirm, активируется и получает 300 ₽ при подтверждении.
|
||||
*/
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
public function register(RegisterRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $service->register(
|
||||
$request->string('email')->toString(),
|
||||
$request->string('password')->toString(),
|
||||
$request->input('captcha_token'),
|
||||
$request->ip(),
|
||||
);
|
||||
} catch (RegistrationException $e) {
|
||||
return $this->registrationError($e);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'status' => $result['status'],
|
||||
'email' => $result['user']->email,
|
||||
'expires_at' => $result['verification']->expires_at->toIso8601String(),
|
||||
];
|
||||
if ($result['dev_code'] !== null) {
|
||||
$payload['_dev_plain_code'] = $result['dev_code'];
|
||||
}
|
||||
|
||||
return response()->json($payload, 201);
|
||||
}
|
||||
|
||||
public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = $service->confirm(
|
||||
$request->string('email')->toString(),
|
||||
$request->string('code')->toString(),
|
||||
);
|
||||
} catch (RegistrationException $e) {
|
||||
$payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason];
|
||||
if ($e->attemptsRemaining !== null) {
|
||||
$payload['attempts_remaining'] = $e->attemptsRemaining;
|
||||
}
|
||||
|
||||
return response()->json($payload, 422);
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
$devCode = $service->resend($request->string('email')->toString());
|
||||
|
||||
$payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.'];
|
||||
if ($devCode !== null) {
|
||||
$payload['_dev_plain_code'] = $devCode;
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
private function registrationError(RegistrationException $e): JsonResponse
|
||||
{
|
||||
$map = [
|
||||
'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'],
|
||||
'email_taken' => ['email', 'Аккаунт с таким email уже существует.'],
|
||||
];
|
||||
[$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.'];
|
||||
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'errors' => [$field => [$message]],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** @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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Reminder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Reminders API (schema v8.10 §17.5). Все endpoint'ы под `auth:sanctum`.
|
||||
*
|
||||
* Фильтры filter= для GET /api/reminders:
|
||||
* today — completed_at IS NULL AND remind_at в (now-1d, now+1d)
|
||||
* upcoming — completed_at IS NULL AND remind_at > now+1d
|
||||
* overdue — completed_at IS NULL AND remind_at < now-1d
|
||||
* completed — completed_at IS NOT NULL
|
||||
* active — completed_at IS NULL (default)
|
||||
*
|
||||
* RLS: внутри транзакции SET LOCAL app.current_tenant_id = $user->tenant_id.
|
||||
* Защита от кражи: явный where('tenant_id', $user->tenant_id) поверх RLS.
|
||||
*/
|
||||
class ReminderController extends Controller
|
||||
{
|
||||
private const FILTERS = ['active', 'today', 'upcoming', 'overdue', 'completed'];
|
||||
|
||||
/**
|
||||
* GET /api/reminders?filter=&deal_id=&limit=
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'filter' => 'nullable|string|in:'.implode(',', self::FILTERS),
|
||||
'deal_id' => 'nullable|integer|min:1',
|
||||
'limit' => 'nullable|integer|min:1|max:200',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$filter = $validated['filter'] ?? 'active';
|
||||
$limit = (int) ($validated['limit'] ?? 100);
|
||||
|
||||
return DB::transaction(function () use ($user, $filter, $validated, $limit): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$query = Reminder::query()
|
||||
->with('creator:id,email,first_name,last_name')
|
||||
->where('tenant_id', $user->tenant_id);
|
||||
|
||||
if (isset($validated['deal_id'])) {
|
||||
$query->where('deal_id', (int) $validated['deal_id']);
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
switch ($filter) {
|
||||
case 'today':
|
||||
$query->whereNull('completed_at')
|
||||
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()]);
|
||||
break;
|
||||
case 'upcoming':
|
||||
$query->whereNull('completed_at')
|
||||
->where('remind_at', '>', $now->copy()->addDay());
|
||||
break;
|
||||
case 'overdue':
|
||||
$query->whereNull('completed_at')
|
||||
->where('remind_at', '<', $now->copy()->subDay());
|
||||
break;
|
||||
case 'completed':
|
||||
$query->whereNotNull('completed_at');
|
||||
break;
|
||||
case 'active':
|
||||
default:
|
||||
$query->whereNull('completed_at');
|
||||
break;
|
||||
}
|
||||
|
||||
$items = $query->orderBy('remind_at')->limit($limit)->get();
|
||||
|
||||
// Counters для UI badges (today/upcoming/overdue) — отдельные SELECT'ы.
|
||||
$base = Reminder::query()->where('tenant_id', $user->tenant_id);
|
||||
$counts = [
|
||||
'today' => (clone $base)->whereNull('completed_at')
|
||||
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()])
|
||||
->count(),
|
||||
'upcoming' => (clone $base)->whereNull('completed_at')
|
||||
->where('remind_at', '>', $now->copy()->addDay())
|
||||
->count(),
|
||||
'overdue' => (clone $base)->whereNull('completed_at')
|
||||
->where('remind_at', '<', $now->copy()->subDay())
|
||||
->count(),
|
||||
'active' => (clone $base)->whereNull('completed_at')->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'items' => $items->map(fn (Reminder $r) => $this->toResource($r))->all(),
|
||||
'counts' => $counts,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/reminders {deal_id, text?, remind_at}.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'deal_id' => 'required|integer|min:1',
|
||||
'text' => 'nullable|string|max:255',
|
||||
'remind_at' => 'required|date',
|
||||
'assignee_id' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
// Manager FK guard для assignee_id: должен принадлежать тому же tenant'у.
|
||||
if (isset($validated['assignee_id'])) {
|
||||
$exists = User::query()
|
||||
->where('id', $validated['assignee_id'])
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
return response()->json([
|
||||
'message' => 'Менеджер не найден в этом тенанте.',
|
||||
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $validated): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'deal_id' => (int) $validated['deal_id'],
|
||||
'text' => $validated['text'] ?? null,
|
||||
'remind_at' => Carbon::parse($validated['remind_at']),
|
||||
'created_by' => $user->id,
|
||||
'assignee_id' => $validated['assignee_id'] ?? null,
|
||||
'is_sent' => false,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->load('creator:id,email,first_name,last_name')),
|
||||
], 201);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/reminders/{id} {text?, remind_at?, assignee_id?}.
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'text' => 'nullable|string|max:255',
|
||||
'remind_at' => 'nullable|date',
|
||||
'assignee_id' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
if (count($validated) === 0) {
|
||||
return response()->json([
|
||||
'message' => 'Передайте хотя бы одно поле.',
|
||||
'errors' => ['_general' => ['Нужно хотя бы одно поле для обновления.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (isset($validated['assignee_id'])) {
|
||||
$exists = User::query()
|
||||
->where('id', $validated['assignee_id'])
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
return response()->json([
|
||||
'message' => 'Менеджер не найден.',
|
||||
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $id, $validated): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->first();
|
||||
|
||||
if ($reminder === null) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
$update = [];
|
||||
if (array_key_exists('text', $validated)) {
|
||||
$update['text'] = $validated['text'];
|
||||
}
|
||||
if (isset($validated['remind_at'])) {
|
||||
$update['remind_at'] = Carbon::parse($validated['remind_at']);
|
||||
// При сдвиге remind_at сбрасываем is_sent, чтобы cron смог
|
||||
// снова отправить уведомление к новому времени.
|
||||
$update['is_sent'] = false;
|
||||
$update['sent_at'] = null;
|
||||
}
|
||||
if (array_key_exists('assignee_id', $validated)) {
|
||||
$update['assignee_id'] = $validated['assignee_id'];
|
||||
}
|
||||
|
||||
$reminder->update($update);
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->fresh('creator')),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/reminders/{id}/complete — пометить выполненным.
|
||||
* Идемпотентно: повторный вызов NO-OP.
|
||||
*/
|
||||
public function complete(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->first();
|
||||
|
||||
if ($reminder === null) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
if ($reminder->completed_at === null) {
|
||||
$reminder->update(['completed_at' => Carbon::now()]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->fresh('creator')),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/reminders/{id}.
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$deleted = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->delete();
|
||||
|
||||
if ($deleted === 0) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Удалено.']);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function toResource(Reminder $reminder): array
|
||||
{
|
||||
$creator = $reminder->creator;
|
||||
|
||||
return [
|
||||
'id' => $reminder->id,
|
||||
'deal_id' => $reminder->deal_id,
|
||||
'text' => $reminder->text,
|
||||
'remind_at' => $reminder->remind_at?->toIso8601String(),
|
||||
'completed_at' => $reminder->completed_at?->toIso8601String(),
|
||||
'is_sent' => $reminder->is_sent,
|
||||
'sent_at' => $reminder->sent_at?->toIso8601String(),
|
||||
'created_at' => $reminder->created_at?->toIso8601String(),
|
||||
'created_by' => $reminder->created_by,
|
||||
'assignee_id' => $reminder->assignee_id,
|
||||
'creator_name' => $creator
|
||||
? trim(($creator->first_name ?? '').' '.($creator->last_name ?? '')) ?: $creator->email
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -44,22 +44,9 @@ class SupplierWebhookController extends Controller
|
||||
/** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
|
||||
private const RATE_LIMIT_PER_MINUTE = 600;
|
||||
|
||||
public function receive(Request $request, string $secret = ''): JsonResponse
|
||||
public function receive(Request $request, string $secret): JsonResponse
|
||||
{
|
||||
// Аутентификация (аддитивно): URL-секрет (backward-compat) ИЛИ HMAC-подпись
|
||||
// тела (X-Webhook-Signature = hash_hmac sha256 от raw body на том же
|
||||
// supplier_webhook_secret). HMAC позволяет поставщику не слать секрет в URL
|
||||
// — тот течёт в access-логи (P2/E4). verifySecret('') всегда false.
|
||||
$sig = (string) $request->header('X-Webhook-Signature', '');
|
||||
$sig = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig;
|
||||
$secretRow = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
|
||||
$expectedSecret = $secretRow !== null ? (string) $secretRow->value : '';
|
||||
$hmacValid = $sig !== ''
|
||||
&& $expectedSecret !== '__SET_ON_DEPLOY__'
|
||||
&& strlen($expectedSecret) >= 32
|
||||
&& hash_equals(hash_hmac('sha256', $request->getContent(), $expectedSecret), $sig);
|
||||
|
||||
if (! $this->verifySecret($secret) && ! $hmacValid) {
|
||||
if (! $this->verifySecret($secret)) {
|
||||
$this->logSupplierWebhook($request, null, 'rejected_secret');
|
||||
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LookupInnRequest;
|
||||
use App\Http\Requests\UpdateRequisitesRequest;
|
||||
use App\Http\Resources\RequisitesResource;
|
||||
use App\Models\TenantRequisites;
|
||||
use App\Services\DaData\PartyLookup;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TenantRequisitesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequisitesService $service,
|
||||
private readonly PartyLookup $party,
|
||||
) {}
|
||||
|
||||
/** GET /api/tenant/requisites */
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$req = TenantRequisites::where('tenant_id', $request->user()->tenant_id)->first();
|
||||
|
||||
return response()->json(['data' => $req ? new RequisitesResource($req) : null]);
|
||||
}
|
||||
|
||||
/** PUT /api/tenant/requisites */
|
||||
public function update(UpdateRequisitesRequest $request): JsonResponse
|
||||
{
|
||||
$req = $this->service->upsert($request->user()->tenant, $request->validated());
|
||||
|
||||
return response()->json(['data' => new RequisitesResource($req)]);
|
||||
}
|
||||
|
||||
/** POST /api/tenant/requisites/lookup-inn — мягкая подтяжка, ничего не сохраняет */
|
||||
public function lookupInn(LookupInnRequest $request): JsonResponse
|
||||
{
|
||||
$res = $this->party->findByInn($request->validated()['inn']);
|
||||
if ($res === null) {
|
||||
return response()->json(['found' => false]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'found' => true,
|
||||
'legal_name' => $res->legalName,
|
||||
'kpp' => $res->kpp,
|
||||
'ogrn' => $res->ogrn,
|
||||
'legal_address' => $res->address,
|
||||
'subject_type_hint' => $res->type === 'INDIVIDUAL' ? 'sole_proprietor' : 'legal_entity',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use App\Http\Requests\Auth\UseRecoveryCodeRequest;
|
||||
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecoveryCode;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -98,7 +97,6 @@ class TwoFactorController extends Controller
|
||||
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_verify_success',
|
||||
@@ -202,7 +200,6 @@ class TwoFactorController extends Controller
|
||||
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_recovery_used',
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Публичный read-API сделок тенанта (G6). Аутентификация — middleware ApiKeyAuth
|
||||
* (tenant_id в request->attributes['api_tenant_id']). Только сделки (deals), не
|
||||
* supplier_leads.
|
||||
*/
|
||||
class DealsController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->attributes->get('api_tenant_id');
|
||||
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
|
||||
$since = trim((string) $request->query('since', ''));
|
||||
$sinceDt = null;
|
||||
if ($since !== '') {
|
||||
try {
|
||||
$sinceDt = Carbon::parse($since);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Невалидный since.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
$cursor = null;
|
||||
if ($cursorRaw !== '') {
|
||||
$decoded = base64_decode($cursorRaw, true);
|
||||
$parsed = $decoded === false ? null : json_decode($decoded, true);
|
||||
if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
|
||||
return response()->json(['message' => 'Невалидный cursor.'], 422);
|
||||
}
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$rows, $next] = DB::transaction(function () use ($tenantId, $limit, $sinceDt, $cursor) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$query = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('project:id,name');
|
||||
|
||||
if ($sinceDt !== null) {
|
||||
$query->where('received_at', '>=', $sinceDt);
|
||||
}
|
||||
if ($cursor !== null) {
|
||||
$query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
|
||||
}
|
||||
|
||||
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
||||
->limit($limit + 1)->get();
|
||||
|
||||
$hasNext = $rows->count() > $limit;
|
||||
if ($hasNext) {
|
||||
$rows = $rows->slice(0, $limit)->values();
|
||||
}
|
||||
|
||||
$next = null;
|
||||
if ($hasNext && $rows->isNotEmpty()) {
|
||||
$last = $rows->last();
|
||||
$next = base64_encode((string) json_encode([
|
||||
'r' => $last->received_at->toIso8601String(),
|
||||
'i' => $last->id,
|
||||
]));
|
||||
}
|
||||
|
||||
return [$rows, $next];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
'received_at' => $d->received_at->toIso8601String(),
|
||||
'phone' => $d->phone,
|
||||
'contact_name' => $d->contact_name,
|
||||
'city' => $d->city,
|
||||
'status' => $d->status,
|
||||
'project' => $d->project?->name,
|
||||
])->all(),
|
||||
'next_cursor' => $next,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -127,16 +127,15 @@ class WebhookSettingsController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
// SSRF-гард + DNS-rebind пиннинг: ОДИН резолв target_url даёт причину
|
||||
// блокировки И безопасный IP. Блокируем адреса во внутренней/зарезервированной
|
||||
// сети (cloud-metadata 169.254.169.254, loopback, RFC1918), которые
|
||||
// https://-валидация на сохранении не ловит.
|
||||
$delivery = WebhookUrlGuard::safeDeliveryIp($sub->target_url);
|
||||
if ($delivery['blockReason'] !== null) {
|
||||
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
|
||||
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
|
||||
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
|
||||
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
|
||||
if ($blockReason !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'status' => null,
|
||||
'message' => $delivery['blockReason'],
|
||||
'message' => $blockReason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
@@ -146,19 +145,9 @@ class WebhookSettingsController extends Controller
|
||||
'message' => 'Тестовая доставка webhook от Лидерра.',
|
||||
];
|
||||
|
||||
// DNS-rebind пиннинг: подключаемся к УЖЕ проверенному IP, не давая
|
||||
// HTTP-клиенту резолвить хост повторно (TOCTOU). Host/SNI — исходный хост.
|
||||
$httpOptions = [];
|
||||
if ($delivery['ip'] !== null) {
|
||||
$host = trim((string) parse_url($sub->target_url, PHP_URL_HOST), '[]');
|
||||
$port = parse_url($sub->target_url, PHP_URL_PORT) ?? 443;
|
||||
$httpOptions['curl'] = [CURLOPT_RESOLVE => ["{$host}:{$port}:{$delivery['ip']}"]];
|
||||
}
|
||||
|
||||
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
|
||||
try {
|
||||
$response = Http::withOptions($httpOptions)
|
||||
->timeout(10)
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
||||
->post($sub->target_url, $testPayload);
|
||||
|
||||
|
||||
@@ -21,14 +21,6 @@ trait ResolvesAdminUserId
|
||||
{
|
||||
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
|
||||
{
|
||||
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
|
||||
// admin-id из конфига, не обращаясь к таблице (фикс 500 на admin-сохранениях).
|
||||
// null (dev/test, суперюзер) → fallback на старую логику ниже.
|
||||
$configured = config('admin.audit_system_user_id');
|
||||
if ($configured !== null) {
|
||||
return (int) $configured;
|
||||
}
|
||||
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Аутентификация публичного API по ключу тенанта (G6).
|
||||
*
|
||||
* Ключ — `Authorization: Bearer lpkapi_...`. В БД лежит bcrypt key_hash + key_prefix
|
||||
* (первые 10 символов). Ищем кандидатов по префиксу через pgsql_supplier (BYPASSRLS —
|
||||
* публичный роут не ставит tenant-GUC, под RLS api_keys вернул бы пусто), затем
|
||||
* Hash::check. Успех → tenant_id в request->attributes (api_tenant_id) + last_used.
|
||||
*/
|
||||
class ApiKeyAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $this->bearer($request);
|
||||
if ($key === null || $key === '') {
|
||||
return response()->json(['message' => 'Требуется API-ключ.'], 401);
|
||||
}
|
||||
|
||||
$prefix = substr($key, 0, 10);
|
||||
|
||||
$candidates = ApiKey::on('pgsql_supplier')
|
||||
->where('key_prefix', $prefix)
|
||||
->where('is_active', true)
|
||||
->where('expires_at', '>', now())
|
||||
->get();
|
||||
|
||||
$matched = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if (Hash::check($key, (string) $candidate->key_hash)) {
|
||||
$matched = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === null) {
|
||||
return response()->json(['message' => 'Неверный или неактивный API-ключ.'], 401);
|
||||
}
|
||||
|
||||
if (! in_array('read', (array) $matched->scopes, true)) {
|
||||
return response()->json(['message' => 'Недостаточно прав ключа.'], 403);
|
||||
}
|
||||
|
||||
ApiKey::on('pgsql_supplier')->whereKey($matched->getKey())->update([
|
||||
'last_used_at' => now(),
|
||||
'last_used_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
$request->attributes->set('api_tenant_id', (int) $matched->tenant_id);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function bearer(Request $request): ?string
|
||||
{
|
||||
$header = (string) $request->header('Authorization', '');
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
return trim(substr($header, 7));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,41 +24,13 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* admin_user_id для audit-trail по-прежнему резолвится трейтом
|
||||
* ResolvesAdminUserId (стаб super_admin) — это отдельная зона.
|
||||
*
|
||||
* G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
|
||||
* вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
|
||||
*
|
||||
* M-1 (приёмка 21.06): nginx-дверь дополнена app-слойным fail-closed гейтом по
|
||||
* REMOTE_USER + config-allowlist. Закрывает обходы front-controller'а
|
||||
* (/index.php/api/admin, /API/admin), где nginx basic-auth не применяется и
|
||||
* REMOTE_USER пуст. См. config/admin.php и spec 2026-06-21-m1-admin-gate-fail-closed.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
|
||||
* guard (Yandex 360 SSO-сессия + роль).
|
||||
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
|
||||
// вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
|
||||
$hasMarker = $request->hasSession() && $request->session()->has('impersonation');
|
||||
$hasBearer = str_starts_with((string) $request->header('Authorization', ''), 'Bearer lpimp_');
|
||||
if ($hasMarker || $hasBearer) {
|
||||
abort(403, 'Во время сессии impersonation доступ в админ-зону запрещён.');
|
||||
}
|
||||
|
||||
// M-1 (приёмка 21.06): fail-closed гейт. REMOTE_USER непуст только у запросов,
|
||||
// прошедших nginx admin-basic-auth (^~ /admin, ^~ /api/admin); обходы через
|
||||
// front-controller (/index.php/api/admin, /API/admin) попадают в auth_basic off
|
||||
// → REMOTE_USER пуст → 403. В local/testing гейт выключен (см. config/admin.php).
|
||||
if (config('admin.basic_auth_gate')) {
|
||||
$remoteUser = (string) $request->server('REMOTE_USER', '');
|
||||
$allowlist = (array) config('admin.basic_auth_allowlist', []);
|
||||
if ($remoteUser === '' || ! in_array($remoteUser, $allowlist, true)) {
|
||||
abort(403, 'Доступ в админ-зону запрещён.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Services\Pd\ImpersonationExpiryService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* G7-B: на web-запросах с активным impersonation-маркером проверяет 60-мин
|
||||
* лимит. Истёк (или токен уже неактивен) → завершает токен, шлёт письмо,
|
||||
* разлогинивает, чистит маркер. No-op, если маркера нет.
|
||||
*
|
||||
* Отправка письма делегирована ImpersonationExpiryService (Service-слой) —
|
||||
* middleware не зависит от слоя Mail напрямую (deptrac ruleset).
|
||||
*/
|
||||
class ImpersonationContext
|
||||
{
|
||||
public function __construct(private readonly ImpersonationExpiryService $expiry) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->hasSession() || ! $request->session()->has('impersonation')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$marker = $request->session()->get('impersonation');
|
||||
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id'] ?? 0);
|
||||
|
||||
if ($token === null || ! $token->isSessionActive()) {
|
||||
if ($token !== null) {
|
||||
$this->expiry->endSession($token);
|
||||
}
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->forget('impersonation');
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Завершаем текущий запрос немедленно — auth:sanctum уже мог
|
||||
// резолвить user'а из сессии, поэтому не передаём $next, а
|
||||
// возвращаем 401 явно, чтобы клиент знал о разрыве сессии.
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Сессия impersonation истекла.'], 401);
|
||||
}
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Account;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/account/change-password (UI-аудит 21.06.2026, Security).
|
||||
*
|
||||
* current_password — текущий пароль (проверка совпадения — в контроллере через
|
||||
* Hash::check против колонки password_hash). password — новый, min 10 (ТЗ §22.4.1,
|
||||
* как reset-flow) + confirmed (password_confirmation).
|
||||
*/
|
||||
class ChangePasswordRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => ['required', 'string'],
|
||||
'password' => ['required', 'confirmed', Password::min(10)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'current_password.required' => 'Укажите текущий пароль.',
|
||||
'password.required' => 'Укажите новый пароль.',
|
||||
'password.confirmed' => 'Пароли не совпадают.',
|
||||
'password.min' => 'Пароль должен быть не короче 10 символов.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/confirm-email — подтверждение почты 6-значным кодом.
|
||||
*/
|
||||
class ConfirmEmailRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Укажите email.',
|
||||
'code.required' => 'Укажите код из письма.',
|
||||
'code.regex' => 'Код состоит из 6 цифр.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,21 +18,14 @@ class RegisterRequest extends FormRequest
|
||||
{
|
||||
use HasPasswordRules;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* NB: уникальность email НЕ через DB-rule — её решает RegistrationService
|
||||
* (активный email → 422 email_taken; неподтверждённый → перевыпуск кода).
|
||||
* Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST).
|
||||
*/
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => $this->passwordRules(),
|
||||
'accept_offer' => ['required', 'accepted'],
|
||||
'accept_pdn' => ['required', 'accepted'],
|
||||
'captcha_token' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,9 +35,9 @@ class RegisterRequest extends FormRequest
|
||||
return array_merge($this->passwordMessages(), [
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
'email.unique' => 'Аккаунт с таким email уже существует.',
|
||||
'accept_offer.accepted' => 'Необходимо принять оферту.',
|
||||
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
|
||||
'captcha_token.required' => 'Подтвердите, что вы не робот.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/resend-code — повторная отправка кода подтверждения.
|
||||
*/
|
||||
class ResendCodeRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LookupInnRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'inn' => ['required', 'string', 'regex:/^(\d{10}|\d{12})$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\InnValidator;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateRequisitesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
$subjectType = (string) $this->input('subject_type');
|
||||
|
||||
return [
|
||||
'subject_type' => ['required', Rule::in(['individual', 'sole_proprietor', 'legal_entity'])],
|
||||
'contact_name' => ['required', 'string', 'max:255'],
|
||||
'contact_phone' => ['required', 'string', function ($attr, $value, $fail) {
|
||||
if (PhoneNormalizer::normalize((string) $value) === null) {
|
||||
$fail('Некорректный телефон.');
|
||||
}
|
||||
}],
|
||||
'inn' => [
|
||||
Rule::requiredIf(in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)),
|
||||
'nullable', 'string',
|
||||
function ($attr, $value, $fail) use ($subjectType) {
|
||||
if (in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)
|
||||
&& is_string($value) && $value !== ''
|
||||
&& ! InnValidator::isValid($value, $subjectType)) {
|
||||
$fail('Некорректный ИНН (контрольная цифра).');
|
||||
}
|
||||
},
|
||||
],
|
||||
'legal_name' => ['nullable', 'string', 'max:255'],
|
||||
'kpp' => ['nullable', 'string', 'regex:/^\d{9}$/'],
|
||||
'ogrn' => ['nullable', 'string', 'regex:/^(\d{13}|\d{15})$/'],
|
||||
'legal_address' => ['nullable', 'string'],
|
||||
'bank_name' => ['nullable', 'string', 'max:255'],
|
||||
'bank_bik' => ['nullable', 'string', 'regex:/^\d{9}$/'],
|
||||
'bank_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
|
||||
'corr_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\TenantRequisites;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin TenantRequisites */
|
||||
class RequisitesResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'subject_type' => $this->subject_type,
|
||||
'contact_name' => $this->contact_name,
|
||||
'contact_phone' => $this->contact_phone,
|
||||
'inn' => $this->inn,
|
||||
'legal_name' => $this->legal_name,
|
||||
'kpp' => $this->kpp,
|
||||
'ogrn' => $this->ogrn,
|
||||
'legal_address' => $this->legal_address,
|
||||
'bank_name' => $this->bank_name,
|
||||
'bank_bik' => $this->bank_bik,
|
||||
'bank_account' => $this->bank_account,
|
||||
'corr_account' => $this->corr_account,
|
||||
'requisites_completed_at' => $this->requisites_completed_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -605,7 +605,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int> '{82,83}' → [82,83]; '{}'/'' → []
|
||||
* @return list<int> '{82,83}' → [82,83]; '{}'/'' → []
|
||||
*/
|
||||
private function parseSubjectCodes(string $regionsLiteral): array
|
||||
{
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* G2-A: раз в 30 минут (routes/console.php) рассылает дайджест новых сделок.
|
||||
* Окно — последние 30 минут по received_at.
|
||||
*
|
||||
* Идемпотентность по СДЕЛКЕ (N-4): окно даёт защиту только при ровно-30-мин
|
||||
* прогонах; ручной/повторный прогон (R3b велит дёргать вручную) перекрывает окно.
|
||||
* Поэтому каждая сделка, попавшая в дайджест, помечается в Redis (TTL 1 сутки) —
|
||||
* повторно в дайджест не включается. Пометка ставится ПОСЛЕ успешной отправки
|
||||
* (mark-after-send): падение джоба до неё оставит сделки непомеченными → очередь
|
||||
* повторит (at-least-once вместо тихой потери). Один воркер → гонки нет.
|
||||
*/
|
||||
final class SendNewLeadsDigestJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
public function handle(NotificationService $notifier): void
|
||||
{
|
||||
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (EloquentCollection $tenants) use ($notifier): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->digestForTenant($tenant, $notifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function digestForTenant(Tenant $tenant, NotificationService $notifier): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $notifier): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
|
||||
|
||||
$deals = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('received_at', '>', now()->subMinutes(30))
|
||||
->where('is_test', false)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('received_at')
|
||||
->get();
|
||||
|
||||
// N-4: исключаем сделки, уже попавшие в прошлый дайджест (без side-effect).
|
||||
$fresh = $deals->reject(
|
||||
fn (Deal $deal): bool => Cache::has('digest_sent:'.$deal->id)
|
||||
);
|
||||
|
||||
if ($fresh->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notifier->notifyNewLeadsDigest($tenant, $fresh);
|
||||
|
||||
// N-4: помечаем ТОЛЬКО ПОСЛЕ успешного возврата notify. Падение джоба
|
||||
// до этой точки оставит сделки непомеченными → очередь повторит прогон
|
||||
// (at-least-once вместо тихой потери дайджеста). Один воркер (см.
|
||||
// prod-logic-map §18.4) → гонки между прогонами нет. TTL 1 сутки.
|
||||
foreach ($fresh as $deal) {
|
||||
Cache::put('digest_sent:'.$deal->id, 1, now()->addDay());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
final class SnapshotProjectRoutingJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS
|
||||
|
||||
@@ -38,11 +38,10 @@ final class SnapshotProjectRoutingJob implements ShouldQueue
|
||||
->exists();
|
||||
if ($exists) {
|
||||
Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$count = DB::connection(self::DB_CONNECTION)->insert(<<<'SQL'
|
||||
$count = DB::connection(self::DB_CONNECTION)->insert(<<<SQL
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
|
||||
@@ -6,12 +6,9 @@ namespace App\Jobs\Supplier;
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Mail\CsvDriftAlertMail;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Mail\TenantBusinessDriftAlertMail;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\Supplier\SupplierCsvParser;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Cache\LockProvider;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
@@ -63,9 +60,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
private const LOCK_TTL_SECONDS = 600;
|
||||
|
||||
/** UI-аудит 21.06: не чаще 1 алерта о падении сверки за это окно (анти-спам). */
|
||||
private const FAILURE_ALERT_THROTTLE_HOURS = 6;
|
||||
|
||||
public function handle(
|
||||
SupplierPortalClient $portal,
|
||||
SupplierCsvParser $parser,
|
||||
@@ -218,26 +212,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
$this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// UI-аудит 21.06: раньше падение сверки писалось в лог status=failed,
|
||||
// но НИКОГО не уведомляло (алерт слался только на drift) — а heartbeat
|
||||
// показывал «OK» (Schedule::job меряет постановку в очередь, не результат).
|
||||
// Из-за этого заход к поставщику падал каждые 30 мин ~3 недели незаметно.
|
||||
// Теперь: при падении шлём critical-алерт на ops-email, троттл 6ч.
|
||||
$alertSent = false;
|
||||
if (! $this->failureAlertRecentlySent()) {
|
||||
try {
|
||||
$mailer->to((string) config('services.supplier.alert_email'))
|
||||
->send(new SupplierCriticalAlertMail(
|
||||
alertType: 'csv_reconcile_failed',
|
||||
details: 'Сверка с поставщиком (CsvReconcileJob) падает. Ошибка: '
|
||||
.substr($e->getMessage(), 0, 500),
|
||||
));
|
||||
$alertSent = true;
|
||||
} catch (Throwable $mailError) {
|
||||
Log::error('csv_reconcile.failure_alert_send_failed', ['error' => $mailError->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
|
||||
if ($logId !== null) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
@@ -247,7 +221,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
'finished_at' => now(),
|
||||
'status' => 'failed',
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'alert_email_sent_at' => $alertSent ? now() : null,
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
@@ -264,16 +237,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
return trim($phone).'|'.trim($project);
|
||||
}
|
||||
|
||||
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
|
||||
private function failureAlertRecentlySent(): bool
|
||||
{
|
||||
return DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->whereNotNull('alert_email_sent_at')
|
||||
->where('alert_email_sent_at', '>=', now()->subHours(self::FAILURE_ALERT_THROTTLE_HOURS))
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform из имени проекта:
|
||||
* - `B[123]_<rest>` → 'B1' / 'B2' / 'B3';
|
||||
@@ -311,8 +274,8 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
private function detectAndAlertBusinessDrift(
|
||||
Mailer $mailer,
|
||||
CarbonInterface $windowStart,
|
||||
CarbonInterface $windowEnd,
|
||||
\Carbon\CarbonInterface $windowStart,
|
||||
\Carbon\CarbonInterface $windowEnd,
|
||||
): void {
|
||||
$from = $windowStart->toDateString();
|
||||
$to = $windowEnd->toDateString();
|
||||
@@ -337,7 +300,7 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
$mailer->to((string) config('services.supplier.alert_email'))
|
||||
->send(new TenantBusinessDriftAlertMail(
|
||||
->send(new \App\Mail\TenantBusinessDriftAlertMail(
|
||||
tenantId: (int) $row->tenant_id,
|
||||
snapshotDate: (string) $row->snapshot_date,
|
||||
expected: $expected,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\ProcessorInterface;
|
||||
|
||||
/**
|
||||
* Monolog-процессор: маскирует ПДн в логах перед записью.
|
||||
*
|
||||
* Закрывает Medium go-live: laravel.log (LOG_LEVEL=debug) мог сохранить телефон/email
|
||||
* открытым, если они попадут в текст исключения или контекст. Процессор ловит ВСЕ
|
||||
* записи каналов, к которым подключён (см. App\Logging\ScrubPii + config/logging.php),
|
||||
* централизованно — надёжнее правки отдельных вызовов Log::.
|
||||
*/
|
||||
final class PiiScrubbingProcessor implements ProcessorInterface
|
||||
{
|
||||
/**
|
||||
* Телефоны РФ: 11 цифр в формате 7XXXXXXXXXX / 8XXXXXXXXXX / +7XXXXXXXXXX.
|
||||
* Lookbehind/lookahead не дают маскировать часть более длинной цифровой строки
|
||||
* (например 14-значный технический id).
|
||||
*/
|
||||
private const PHONE_PATTERN = '/(?<!\d)(?:\+?7|8)\d{10}(?!\d)/';
|
||||
|
||||
private const EMAIL_PATTERN = '/[\p{L}0-9._%+\-]+@[\p{L}0-9.\-]+\.\p{L}{2,}/u';
|
||||
|
||||
public function __invoke(LogRecord $record): LogRecord
|
||||
{
|
||||
return $record->with(
|
||||
message: $this->scrub($record->message),
|
||||
context: $this->scrubArray($record->context),
|
||||
extra: $this->scrubArray($record->extra),
|
||||
);
|
||||
}
|
||||
|
||||
private function scrub(string $value): string
|
||||
{
|
||||
$value = preg_replace(self::PHONE_PATTERN, '[PHONE]', $value) ?? $value;
|
||||
|
||||
return preg_replace(self::EMAIL_PATTERN, '[EMAIL]', $value) ?? $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $data
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
private function scrubArray(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$result[$key] = $this->scrub($value);
|
||||
} elseif (is_array($value)) {
|
||||
$result[$key] = $this->scrubArray($value);
|
||||
} else {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Illuminate\Log\Logger;
|
||||
|
||||
/**
|
||||
* Tap для config/logging.php: вешает PiiScrubbingProcessor на канал.
|
||||
*
|
||||
* Использование: 'tap' => [\App\Logging\ScrubPii::class] в описании канала.
|
||||
*/
|
||||
final class ScrubPii
|
||||
{
|
||||
public function __invoke(Logger $logger): void
|
||||
{
|
||||
// Illuminate\Log\Logger::getLogger() типизирован как PSR LoggerInterface,
|
||||
// но фактически возвращает Monolog\Logger (у него есть pushProcessor).
|
||||
$monolog = $logger->getLogger();
|
||||
if ($monolog instanceof \Monolog\Logger) {
|
||||
$monolog->pushProcessor(new PiiScrubbingProcessor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо с 6-значным кодом подтверждения почты при самозаписи (G1/SP1).
|
||||
*/
|
||||
final class EmailVerificationCodeMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $code,
|
||||
public readonly string $email,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Код подтверждения регистрации в Лидерре',
|
||||
to: [$this->email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.email_verification_code',
|
||||
with: ['code' => $this->code],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/** Код-согласие на вход поддержки в кабинет клиента (G7-B / Ю-1). */
|
||||
final class ImpersonationCodeMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $code,
|
||||
public readonly string $email,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Код доступа: запрос входа поддержки в ваш кабинет',
|
||||
to: [$this->email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.impersonation_code', with: ['code' => $this->code]);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/** Уведомление о завершении сессии поддержки в кабинете клиента (G7-B / Ю-1). */
|
||||
final class ImpersonationEndedMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $email,
|
||||
public readonly ?string $tenantName = null,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Сессия поддержки в вашем кабинете завершена',
|
||||
to: [$this->email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.impersonation_ended', with: ['tenantName' => $this->tenantName]);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Письмо-сводка о новых сделках за окно (G2-A дайджест).
|
||||
* Заменяет пер-лид NewLeadNotification как email-канал события new_lead.
|
||||
*
|
||||
* @property Collection<int, Deal> $deals
|
||||
*/
|
||||
class NewLeadsDigestMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public Tenant $tenant,
|
||||
public Collection $deals,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Лидерра. Новые сделки — '.$this->deals->count(),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.new_leads_digest',
|
||||
with: [
|
||||
'user' => $this->user,
|
||||
'tenant' => $this->tenant,
|
||||
'deals' => $this->deals,
|
||||
'count' => $this->deals->count(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Reminder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email-уведомление о наступлении срока напоминания (ТЗ §18.5, событие reminder).
|
||||
*
|
||||
* Триггер: cron-команда `reminders:dispatch-due` находит rows с
|
||||
* `is_sent=false AND completed_at IS NULL AND remind_at <= NOW()`,
|
||||
* вызывает NotificationService::notifyReminder для каждой,
|
||||
* затем ставит `is_sent=true, sent_at=NOW()`.
|
||||
*/
|
||||
class ReminderDueNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $recipient,
|
||||
public Reminder $reminder,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$shortText = $this->reminder->text
|
||||
? mb_substr($this->reminder->text, 0, 60)
|
||||
: 'Срок касания клиента';
|
||||
|
||||
return new Envelope(
|
||||
subject: "Лидерра. Напоминание — {$shortText}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.reminder',
|
||||
with: [
|
||||
'recipient' => $this->recipient,
|
||||
'reminder' => $this->reminder,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Код подтверждения почты при самозаписи клиента (G1/SP1).
|
||||
*
|
||||
* 6-значный код, bcrypt-хеш в code_hash, plain уходит письмом. TTL 15 мин,
|
||||
* 5 попыток. Механика зеркалит ImpersonationToken. Таблица RLS-изолирована
|
||||
* (через user_id→users.tenant_id) — на публичном роуте читается/пишется через
|
||||
* BYPASSRLS pgsql_supplier.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $email
|
||||
* @property string $token
|
||||
* @property string|null $code_hash
|
||||
* @property int $failed_attempts
|
||||
* @property Carbon $expires_at
|
||||
* @property Carbon|null $verified_at
|
||||
* @property Carbon $created_at
|
||||
*/
|
||||
class EmailVerification extends Model
|
||||
{
|
||||
/** schema-таблица не имеет updated_at. */
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'email',
|
||||
'token',
|
||||
'code_hash',
|
||||
'failed_attempts',
|
||||
'expires_at',
|
||||
'verified_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'failed_attempts' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'verified_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return $this->verified_at === null
|
||||
&& $this->failed_attempts < 5
|
||||
&& ! $this->isExpired();
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ use Illuminate\Support\Carbon;
|
||||
* @property int|null $second_approver_id
|
||||
* @property Carbon|null $second_approval_at
|
||||
* @property Carbon $created_at
|
||||
* @property string|null $session_token_hash
|
||||
*
|
||||
* @mixin IdeHelperImpersonationToken
|
||||
*/
|
||||
@@ -55,7 +54,6 @@ class ImpersonationToken extends Model
|
||||
'invalidated_at',
|
||||
'second_approver_id',
|
||||
'second_approval_at',
|
||||
'session_token_hash',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -83,20 +81,6 @@ class ImpersonationToken extends Model
|
||||
&& ! $this->isExpired();
|
||||
}
|
||||
|
||||
/** Сессия impersonation активна: код подтверждён, не завершена, не инвалидирована, в пределах TTL минут. */
|
||||
public function isSessionActive(int $ttlMinutes = 60): bool
|
||||
{
|
||||
return $this->used_at !== null
|
||||
&& $this->session_ended_at === null
|
||||
&& $this->invalidated_at === null
|
||||
&& $this->used_at->copy()->addMinutes($ttlMinutes)->isFuture();
|
||||
}
|
||||
|
||||
public function sessionExpiresAt(int $ttlMinutes = 60): ?Carbon
|
||||
{
|
||||
return $this->used_at?->copy()->addMinutes($ttlMinutes);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
|
||||
|
||||
/**
|
||||
* Расширение Sanctum PersonalAccessToken.
|
||||
*
|
||||
* Перехватывает Bearer-токены с префиксом «lpimp_» (машинные ключи
|
||||
* impersonation-guard, G7-B) и возвращает null без обращения к БД.
|
||||
* Это предотвращает crash при отсутствии таблицы personal_access_tokens
|
||||
* (проект использует SPA cookie-auth, таблица не создаётся).
|
||||
*/
|
||||
class PersonalAccessToken extends SanctumPersonalAccessToken
|
||||
{
|
||||
/**
|
||||
* Find the token instance matching the given token.
|
||||
*
|
||||
* Returns null immediately for impersonation machine-key tokens so
|
||||
* Sanctum does not attempt a DB lookup on personal_access_tokens.
|
||||
*/
|
||||
public static function findToken($token): ?static
|
||||
{
|
||||
if (str_starts_with((string) $token, 'lpimp_')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// В проекте нет таблицы personal_access_tokens (SPA cookie-auth, Sanctum
|
||||
// PAT не используются). Без этого try/catch любой иной Bearer на
|
||||
// sanctum-роуте ронял бы запрос в 500 (Undefined table) вместо чистого
|
||||
// 401. Гасим QueryException до null — guard вернёт 401.
|
||||
try {
|
||||
return parent::findToken($token);
|
||||
} catch (QueryException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ReminderFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Напоминание на сделке (schema v8.10 §17.5).
|
||||
*
|
||||
* Tenant-aware модель с RLS. Композитные индексы:
|
||||
* - idx_reminders_due (remind_at) WHERE is_sent=FALSE AND completed_at IS NULL — cron;
|
||||
* - idx_reminders_deal (deal_id) — UI карточки сделки;
|
||||
* - idx_reminders_tenant_user_active — дашборд «today/last/future».
|
||||
*
|
||||
* deal_id БЕЗ FK (deals партиционирована, FK на partitioned-родительскую
|
||||
* таблицу не поддерживается без partition-key в составе).
|
||||
*
|
||||
* MVP: assignee_id всегда NULL — паритет с histories[].to оригинала. Поле
|
||||
* зарезервировано для Post-MVP (multi-assignee).
|
||||
*
|
||||
* @mixin IdeHelperReminder
|
||||
*/
|
||||
class Reminder extends Model
|
||||
{
|
||||
/** @use HasFactory<ReminderFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'deal_id',
|
||||
'text',
|
||||
'remind_at',
|
||||
'created_by',
|
||||
'assignee_id',
|
||||
'completed_at',
|
||||
'is_sent',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'deal_id' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
'assignee_id' => 'integer',
|
||||
'is_sent' => 'boolean',
|
||||
'remind_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'sent_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 creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function assignee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assignee_id');
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->completed_at !== null;
|
||||
}
|
||||
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
return $this->completed_at === null && $this->remind_at < now();
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.1
|
||||
*
|
||||
* Поля резолва региона (lead-region) аннотированы явно — ide-helper:models
|
||||
* не подхватил их в стаб IdeHelperSupplierLead:
|
||||
*
|
||||
* @property int|null $dadata_qc
|
||||
* @property string|null $phone_operator
|
||||
* @property string|null $region_source
|
||||
* @property int|null $resolved_subject_code
|
||||
*
|
||||
* @mixin IdeHelperSupplierLead
|
||||
*/
|
||||
class SupplierLead extends Model
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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'];
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Реквизиты тенанта (G1/SP2). 1:1 с tenants. RLS по tenant_id.
|
||||
*
|
||||
* Свойства аннотированы явно: `ide-helper:models` пропускает эту модель при
|
||||
* интроспекции (стаб IdeHelperTenantRequisites не генерируется), поэтому
|
||||
*
|
||||
* @property-аннотации заданы вручную по db/schema.sql (tenant_requisites).
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $subject_type
|
||||
* @property string $contact_name
|
||||
* @property string $contact_phone
|
||||
* @property string|null $inn
|
||||
* @property string|null $legal_name
|
||||
* @property string|null $kpp
|
||||
* @property string|null $ogrn
|
||||
* @property string|null $legal_address
|
||||
* @property string|null $bank_name
|
||||
* @property string|null $bank_bik
|
||||
* @property string|null $bank_account
|
||||
* @property string|null $corr_account
|
||||
* @property array<string, mixed>|null $dadata_raw
|
||||
* @property Carbon|null $dadata_synced_at
|
||||
* @property Carbon|null $requisites_completed_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
class TenantRequisites extends Model
|
||||
{
|
||||
protected $table = 'tenant_requisites';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id', 'subject_type', 'contact_name', 'contact_phone',
|
||||
'inn', 'legal_name', 'kpp', 'ogrn', 'legal_address',
|
||||
'bank_name', 'bank_bik', 'bank_account', 'corr_account',
|
||||
'dadata_raw', 'dadata_synced_at', 'requisites_completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'dadata_raw' => 'array',
|
||||
'dadata_synced_at' => 'datetime',
|
||||
'requisites_completed_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,14 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\User;
|
||||
use App\Services\Captcha\CaptchaVerifier;
|
||||
use App\Services\Captcha\NullCaptchaVerifier;
|
||||
use App\Services\Captcha\YandexSmartCaptchaVerifier;
|
||||
use App\Services\DaData\DaDataPartyClient;
|
||||
use App\Services\DaData\NullPartyLookup;
|
||||
use App\Services\DaData\PartyLookup;
|
||||
use App\Services\Supplier\Channel\AjaxProjectChannel;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\FormProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\ProcessFactory;
|
||||
use App\Services\Supplier\SymfonyProcessFactory;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -49,26 +34,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
$app->make(Mailer::class),
|
||||
),
|
||||
);
|
||||
|
||||
// Шов капчи самозаписи (G1/SP1 + M-2). Драйвер по CAPTCHA_DRIVER:
|
||||
// 'yandex' → реальный Yandex SmartCaptcha; иначе (включая 'null') → Null
|
||||
// (dev/test). Контроллер/RegistrationService зовут только интерфейс.
|
||||
$this->app->bind(
|
||||
CaptchaVerifier::class,
|
||||
fn () => match (config('services.captcha.driver')) {
|
||||
'yandex' => new YandexSmartCaptchaVerifier,
|
||||
default => new NullCaptchaVerifier,
|
||||
},
|
||||
);
|
||||
|
||||
// Шов подтяжки организации по ИНН (G1/SP2). По флагу party_enabled —
|
||||
// реальный DaData suggestions; иначе Null (dev/тесты не ходят в сеть).
|
||||
$this->app->bind(
|
||||
PartyLookup::class,
|
||||
fn ($app) => config('services.dadata.party_enabled')
|
||||
? $app->make(DaDataPartyClient::class)
|
||||
: $app->make(NullPartyLookup::class),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,70 +41,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// P1 go-live: per-IP route-throttle поверх прикладного per-credential
|
||||
// rate-limit в auth-контроллерах. Именованные лимитеры изолируют счётчики
|
||||
// login / 2fa / password. Применение — throttle:<name> в routes/web.php.
|
||||
// 20/мин — стартовое значение (выше максимума тестовых циклов), снижать по
|
||||
// боевому трафику. Runbook: docs/superpowers/runbooks/2026-06-17-auth-throttle-limits.md
|
||||
foreach (['auth-login', 'auth-2fa', 'auth-password', 'auth-register'] as $limiterName) {
|
||||
RateLimiter::for(
|
||||
$limiterName,
|
||||
fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown'),
|
||||
);
|
||||
}
|
||||
|
||||
// apiv1-rate (приёмка 21.06): публичный read-API сделок (/api/v1/deals)
|
||||
// прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS
|
||||
// по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера —
|
||||
// сам Bearer-ключ (sha256, «per ключ»); без заголовка — fallback на IP.
|
||||
RateLimiter::for('api-v1', function (Request $request) {
|
||||
$header = (string) $request->header('Authorization', '');
|
||||
$bearer = str_starts_with($header, 'Bearer ')
|
||||
? trim(substr($header, 7))
|
||||
: '';
|
||||
$by = $bearer !== ''
|
||||
? 'k:'.hash('sha256', $bearer)
|
||||
: 'ip:'.($request->ip() ?: 'unknown');
|
||||
|
||||
return Limit::perMinute(120)->by($by);
|
||||
});
|
||||
|
||||
// G7-B: заменяем Sanctum PersonalAccessToken на расширение, которое
|
||||
// возвращает null для lpimp_-токенов без запроса к personal_access_tokens.
|
||||
// Проект использует SPA cookie-auth — таблица personal_access_tokens отсутствует.
|
||||
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
|
||||
|
||||
// G7-B: кастомный auth-guard «impersonation».
|
||||
// По Bearer-токену вида lpimp_<id>_<secret> резолвит первого активного
|
||||
// пользователя тенанта из impersonation_tokens.
|
||||
// Запросы к БД идут через pgsql_supplier (BYPASSRLS), чтобы не упереться
|
||||
// в RLS до SetTenantContext (middleware «tenant» применяется позже).
|
||||
Auth::viaRequest('impersonation', function (Request $request) {
|
||||
$header = (string) $request->header('Authorization', '');
|
||||
if (! str_starts_with($header, 'Bearer lpimp_')) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim(substr($header, 7)); // lpimp_<id>_<secret>
|
||||
$parts = explode('_', $raw, 3);
|
||||
if (count($parts) !== 3 || $parts[0] !== 'lpimp' || ! ctype_digit($parts[1])) {
|
||||
return null;
|
||||
}
|
||||
$idStr = $parts[1];
|
||||
$secret = $parts[2];
|
||||
|
||||
$token = ImpersonationToken::on('pgsql_supplier')->find((int) $idStr);
|
||||
if ($token === null || $token->session_token_hash === null || ! $token->isSessionActive()) {
|
||||
return null;
|
||||
}
|
||||
if (! Hash::check($secret, (string) $token->session_token_hash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::on('pgsql_supplier')
|
||||
->where('tenant_id', $token->tenant_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
});
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Доменная ошибка самозаписи. reason — машинный код для ответа контроллера:
|
||||
* email_taken | captcha_failed | not_found | expired | too_many_attempts | invalid_code.
|
||||
*/
|
||||
final class RegistrationException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $reason,
|
||||
public readonly ?int $attemptsRemaining = null,
|
||||
) {
|
||||
parent::__construct($reason);
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Mail\EmailVerificationCodeMail;
|
||||
use App\Models\EmailVerification;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Captcha\CaptchaVerifier;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Оркестрация самозаписи (G1/SP1): register / confirm / resend.
|
||||
*
|
||||
* Все обращения к tenants/users/email_verifications — через BYPASSRLS-подключение
|
||||
* pgsql_supplier: публичные роуты не выставляют app.current_tenant_id, и под RLS
|
||||
* (роль crm_app_user) SELECT/INSERT по этим таблицам не прошёл бы.
|
||||
*
|
||||
* Гонка дублей: в схеме нет глобального UNIQUE(users.email) (только
|
||||
* UNIQUE(tenant_id,email)), поэтому «проверка-потом-вставка» сериализуется
|
||||
* advisory-локом по email внутри транзакции — два параллельных register на один
|
||||
* новый email не создадут два тенанта (лок снимается на commit/rollback).
|
||||
*/
|
||||
class RegistrationService
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
private const CODE_TTL_MINUTES = 15;
|
||||
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
private const START_BALANCE_RUB = '300.00';
|
||||
|
||||
public function __construct(private readonly CaptchaVerifier $captcha) {}
|
||||
|
||||
/**
|
||||
* @return array{status:string, user:User, verification:EmailVerification, dev_code:?string}
|
||||
*/
|
||||
public function register(string $email, string $password, ?string $captchaToken, ?string $ip): array
|
||||
{
|
||||
if (! $this->captcha->verify($captchaToken, $ip)) {
|
||||
throw new RegistrationException('captcha_failed');
|
||||
}
|
||||
|
||||
$email = mb_strtolower(trim($email));
|
||||
$conn = DB::connection(self::DB_CONNECTION);
|
||||
|
||||
// Сериализация одновременных регистраций одного email (TOCTOU-защита, см. docblock).
|
||||
// Письмо отправляем ПОСЛЕ commit — не держим SMTP внутри транзакции.
|
||||
$issued = $this->atomic(function () use ($conn, $email, $password) {
|
||||
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
|
||||
|
||||
$existing = User::on(self::DB_CONNECTION)->where('email', $email)->first();
|
||||
if ($existing && $existing->is_active) {
|
||||
throw new RegistrationException('email_taken');
|
||||
}
|
||||
|
||||
$user = $existing ?: $this->createPendingTenantOwner($email, $password);
|
||||
|
||||
return $this->createCodeRecord($user);
|
||||
});
|
||||
|
||||
$this->sendCode($issued['user']->email, $issued['plain']);
|
||||
|
||||
return [
|
||||
'status' => 'pending_email_confirm',
|
||||
'user' => $issued['user'],
|
||||
'verification' => $issued['record'],
|
||||
'dev_code' => $issued['dev_code'],
|
||||
];
|
||||
}
|
||||
|
||||
public function confirm(string $email, string $code): User
|
||||
{
|
||||
$email = mb_strtolower(trim($email));
|
||||
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
|
||||
if (! $user) {
|
||||
throw new RegistrationException('not_found');
|
||||
}
|
||||
|
||||
$record = EmailVerification::on(self::DB_CONNECTION)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('verified_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $record || ! $record->isUsable()) {
|
||||
$reason = $record === null ? 'not_found'
|
||||
: ($record->isExpired() ? 'expired' : 'too_many_attempts');
|
||||
throw new RegistrationException($reason);
|
||||
}
|
||||
|
||||
if (! Hash::check($code, (string) $record->code_hash)) {
|
||||
// increment ВНЕ транзакции: счётчик должен пережить 422 (откат сбросил
|
||||
// бы failed_attempts и сломал лимит 5 попыток).
|
||||
$record->increment('failed_attempts');
|
||||
throw new RegistrationException(
|
||||
'invalid_code',
|
||||
max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts),
|
||||
);
|
||||
}
|
||||
|
||||
// Успех — атомарно: пометка кода + активация владельца + статус/баланс тенанта.
|
||||
$this->atomic(function () use ($record, $user): void {
|
||||
$record->update(['verified_at' => now()]);
|
||||
$user->update(['is_active' => true]);
|
||||
Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([
|
||||
'status' => 'active',
|
||||
'balance_rub' => self::START_BALANCE_RUB,
|
||||
]);
|
||||
});
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
/** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */
|
||||
public function resend(string $email): ?string
|
||||
{
|
||||
$email = mb_strtolower(trim($email));
|
||||
$conn = DB::connection(self::DB_CONNECTION);
|
||||
|
||||
$issued = $this->atomic(function () use ($conn, $email) {
|
||||
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
|
||||
|
||||
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
|
||||
if ($user && ! $user->is_active) {
|
||||
return $this->createCodeRecord($user);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if ($issued === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->sendCode($issued['user']->email, $issued['plain']);
|
||||
|
||||
return $issued['dev_code'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнить $work атомарно на pgsql_supplier.
|
||||
*
|
||||
* Прод-путь: соединение НЕ в транзакции → открываем свою (`transaction()`),
|
||||
* advisory xact-lock держится до commit/rollback — корректная сериализация.
|
||||
*
|
||||
* Если PDO УЖЕ в транзакции (внешний caller обернул нас ИЛИ тест-харнес
|
||||
* SharesSupplierPdo делит уже-открытый PDO под DatabaseTransactions) —
|
||||
* участвуем в существующей транзакции без вложенного beginTransaction:
|
||||
* pgsql_supplier-connection не отслеживает уровень внешней транзакции, и
|
||||
* `transaction()` попытался бы `PDO::beginTransaction()` поверх открытой →
|
||||
* «There is already an active transaction». Это nested-transaction-safety,
|
||||
* не тест-специфичная ветка: повторный вызов внутри открытой транзакции
|
||||
* корректно переиспользует её.
|
||||
*
|
||||
* @template T
|
||||
*
|
||||
* @param callable():T $work
|
||||
* @return T
|
||||
*/
|
||||
private function atomic(callable $work): mixed
|
||||
{
|
||||
$conn = DB::connection(self::DB_CONNECTION);
|
||||
|
||||
if ($conn->getPdo()->inTransaction()) {
|
||||
return $work();
|
||||
}
|
||||
|
||||
return $conn->transaction($work);
|
||||
}
|
||||
|
||||
private function createPendingTenantOwner(string $email, string $password): User
|
||||
{
|
||||
$tenant = Tenant::on(self::DB_CONNECTION)->create([
|
||||
'subdomain' => $this->generateSubdomain($email),
|
||||
'organization_name' => $email, // плейсхолдер; уточняется в SP2 (реквизиты)
|
||||
'contact_email' => $email,
|
||||
'balance_rub' => 0,
|
||||
'is_trial' => true,
|
||||
]);
|
||||
// tenants.status НЕ в $fillable модели Tenant (колонка DEFAULT 'active') —
|
||||
// выставляем явно, минуя mass-assignment; иначе самозапись активировала бы
|
||||
// тенанта до подтверждения почты (баг: tenant создавался 'active').
|
||||
$tenant->status = 'pending_email_confirm';
|
||||
$tenant->save();
|
||||
|
||||
return User::on(self::DB_CONNECTION)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $email,
|
||||
'password_hash' => Hash::make($password),
|
||||
'first_name' => 'Новый',
|
||||
'last_name' => 'клиент',
|
||||
'is_active' => false,
|
||||
'totp_enabled' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user:User, record:EmailVerification, plain:string, dev_code:?string}
|
||||
*/
|
||||
private function createCodeRecord(User $user): array
|
||||
{
|
||||
// Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными).
|
||||
EmailVerification::on(self::DB_CONNECTION)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('verified_at')
|
||||
->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]);
|
||||
|
||||
$plain = (string) random_int(100_000, 999_999);
|
||||
|
||||
$record = EmailVerification::on(self::DB_CONNECTION)->create([
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'token' => (string) Str::uuid(),
|
||||
'code_hash' => Hash::make($plain),
|
||||
'failed_attempts' => 0,
|
||||
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
|
||||
]);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'record' => $record,
|
||||
'plain' => $plain,
|
||||
'dev_code' => app()->environment('local', 'testing') ? $plain : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function sendCode(string $email, string $plain): void
|
||||
{
|
||||
// Письмо ставим в очередь (не держим SMTP в HTTP-пути) и НЕ валим самозапись
|
||||
// при сбое доставки: запись кода уже создана, клиент может «отправить повторно».
|
||||
// Email в лог не пишем (ПДн §5.2) — только факт и текст ошибки.
|
||||
try {
|
||||
Mail::to($email)->queue(new EmailVerificationCodeMail($plain, $email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('register: не удалось поставить письмо с кодом в очередь: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function generateSubdomain(string $email): string
|
||||
{
|
||||
$base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value();
|
||||
if ($base === '') {
|
||||
$base = 'client';
|
||||
}
|
||||
$base = Str::limit($base, 50, '');
|
||||
|
||||
$candidate = $base;
|
||||
$i = 0;
|
||||
while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) {
|
||||
$i++;
|
||||
$candidate = $base.$i;
|
||||
}
|
||||
|
||||
return Str::limit($candidate, 63, '');
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Единый расчёт «на сколько дней хватит affordable_leads» (F3, 17.06.2026).
|
||||
*
|
||||
* Единственный источник истины для прогноза runway — используется и биллингом
|
||||
* (BillingController::wallet), и дашбордом (DashboardController::summary), чтобы
|
||||
* исключить расхождение «0 дней (дашборд) ↔ N дней (биллинг)»: раньше дашборд
|
||||
* считал от legacy `balance_leads`, а биллинг — от рублёвого affordable.
|
||||
*
|
||||
* Billing v2 Spec A: affordable_leads — выход BalanceToLeadsConverter (точная
|
||||
* конверсия ₽ по ступеням), делённый на среднюю скорость списания за 30 дней
|
||||
* (count(lead_charges)/30).
|
||||
*
|
||||
* - affordable_leads ≤ 0 → 0 (тенант не может купить ни одного лида).
|
||||
* - leadsLast30Days = 0 → null (нет истории, не от чего считать).
|
||||
* - иначе → floor(affordable_leads / (leadsLast30Days / 30)).
|
||||
*/
|
||||
class RunwayCalculator
|
||||
{
|
||||
public function daysLeft(int $tenantId, int $affordableLeads): ?int
|
||||
{
|
||||
if ($affordableLeads <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$leadsLast30Days = (int) DB::table('lead_charges')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('charged_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
if ($leadsLast30Days <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$avgPerDay = $leadsLast30Days / 30.0;
|
||||
|
||||
return max(0, (int) floor($affordableLeads / $avgPerDay));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Captcha;
|
||||
|
||||
/**
|
||||
* Шов проверки капчи. SP1 — dev-драйвер NullCaptchaVerifier; реальный
|
||||
* Yandex SmartCaptcha-драйвер подключается позже (SP3/ops) без изменения
|
||||
* контроллера/сервиса — они зовут только этот интерфейс.
|
||||
*/
|
||||
interface CaptchaVerifier
|
||||
{
|
||||
/** true — капча пройдена; false — отклонить регистрацию. */
|
||||
public function verify(?string $token, ?string $ip = null): bool;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Captcha;
|
||||
|
||||
/**
|
||||
* Dev/test-драйвер: пустой токен — всегда провал; непустой — исход из конфига
|
||||
* services.captcha.fake_passes (тест переключает на false, чтобы проверить
|
||||
* ветку отклонения). На prod вместо него встанет Yandex SmartCaptcha-драйвер.
|
||||
*/
|
||||
final class NullCaptchaVerifier implements CaptchaVerifier
|
||||
{
|
||||
public function verify(?string $token, ?string $ip = null): bool
|
||||
{
|
||||
if ($token === null || $token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) config('services.captcha.fake_passes', true);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Captcha;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Серверная валидация Yandex SmartCaptcha (M-2). Пустой токен → отказ без
|
||||
* запроса. status=ok → пройдено; status≠ok → отказ. При недоступности Yandex
|
||||
* (таймаут/не-200/сеть/битый JSON) — fail-open (true): UX выше антифрода,
|
||||
* второй рубеж — throttle регистрации. Site-key (фронт-виджет) — отдельно.
|
||||
*/
|
||||
final class YandexSmartCaptchaVerifier implements CaptchaVerifier
|
||||
{
|
||||
public function verify(?string $token, ?string $ip = null): bool
|
||||
{
|
||||
if ($token === null || $token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'secret' => (string) config('services.captcha.yandex_server_key'),
|
||||
'token' => $token,
|
||||
];
|
||||
if ($ip !== null) {
|
||||
$payload['ip'] = $ip;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(2)
|
||||
->asForm()
|
||||
->post((string) config('services.captcha.yandex_validate_url'), $payload);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('captcha.yandex_unreachable', ['http_status' => $response->status()]);
|
||||
|
||||
return true; // fail-open
|
||||
}
|
||||
|
||||
return $response->json('status') === 'ok';
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('captcha.yandex_unreachable', ['error' => $e->getMessage()]);
|
||||
|
||||
return true; // fail-open
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
use App\Services\DaData\Dto\PartyLookupResult;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
|
||||
/**
|
||||
* Suggestions findById/party — подтяжка организации по ИНН (бесплатный эндпоинт DaData).
|
||||
* POST https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party
|
||||
* Authorization: Token <api_key> ; body {"query":"<inn>"}
|
||||
*
|
||||
* Все ошибки (нет ключа / сеть / 4xx / 5xx / пустой ответ) → null (мягко, не бросаем).
|
||||
*/
|
||||
final class DaDataPartyClient implements PartyLookup
|
||||
{
|
||||
private const URL = 'https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party';
|
||||
|
||||
public function __construct(private readonly HttpFactory $http) {}
|
||||
|
||||
public function findByInn(string $inn): ?PartyLookupResult
|
||||
{
|
||||
$cfg = (array) config('services.dadata');
|
||||
$apiKey = (string) ($cfg['api_key'] ?? '');
|
||||
if ($apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
$timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000));
|
||||
|
||||
try {
|
||||
$response = $this->http
|
||||
->asJson()
|
||||
->acceptJson()
|
||||
->timeout($timeoutSec)
|
||||
->withHeaders(['Authorization' => 'Token '.$apiKey])
|
||||
->post(self::URL, ['query' => $inn]);
|
||||
} catch (ConnectionException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->parse($response->json());
|
||||
}
|
||||
|
||||
/** @param mixed $body */
|
||||
private function parse($body): ?PartyLookupResult
|
||||
{
|
||||
$sug = (is_array($body) && isset($body['suggestions'][0]) && is_array($body['suggestions'][0]))
|
||||
? $body['suggestions'][0]
|
||||
: null;
|
||||
if ($sug === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = is_array($sug['data'] ?? null) ? $sug['data'] : [];
|
||||
$name = (string) ($sug['value'] ?? '');
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PartyLookupResult(
|
||||
legalName: $name,
|
||||
kpp: isset($data['kpp']) ? (string) $data['kpp'] : null,
|
||||
ogrn: isset($data['ogrn']) ? (string) $data['ogrn'] : null,
|
||||
address: isset($data['address']['value']) ? (string) $data['address']['value'] : null,
|
||||
type: isset($data['type']) ? (string) $data['type'] : '',
|
||||
raw: $sug,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData\Dto;
|
||||
|
||||
final class PartyLookupResult
|
||||
{
|
||||
/** @param array<string,mixed> $raw */
|
||||
public function __construct(
|
||||
public readonly string $legalName,
|
||||
public readonly ?string $kpp,
|
||||
public readonly ?string $ogrn,
|
||||
public readonly ?string $address,
|
||||
public readonly string $type, // 'LEGAL' | 'INDIVIDUAL' | ''
|
||||
public readonly array $raw,
|
||||
) {}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
use App\Services\DaData\Dto\PartyLookupResult;
|
||||
|
||||
final class NullPartyLookup implements PartyLookup
|
||||
{
|
||||
public function findByInn(string $inn): ?PartyLookupResult
|
||||
{
|
||||
return null; // dev/тесты по умолчанию — «не найдено»
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\DaData;
|
||||
|
||||
use App\Services\DaData\Dto\PartyLookupResult;
|
||||
|
||||
interface PartyLookup
|
||||
{
|
||||
public function findByInn(string $inn): ?PartyLookupResult;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\Deal;
|
||||
use App\Models\ImportLog;
|
||||
use App\Models\ImportUnknownStatus;
|
||||
use App\Models\Project;
|
||||
use App\Models\Reminder;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -163,6 +164,7 @@ final class HistoricalImportService
|
||||
'contact_name' => $row->contactName,
|
||||
'comment' => $row->comment,
|
||||
]);
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -186,6 +188,8 @@ final class HistoricalImportService
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->syncReminder($tenantId, $userId, $deal, $row);
|
||||
|
||||
$this->pdLog->record(
|
||||
action: 'created',
|
||||
subjectType: 'lead',
|
||||
@@ -201,6 +205,35 @@ final class HistoricalImportService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт reminders-строку для непустого «Напоминание» (ТЗ §6.3 — поле
|
||||
* deals.reminder_at удалено в v8.3, заменено таблицей reminders).
|
||||
* Идемпотентно: не дублирует напоминание при повторном импорте.
|
||||
*/
|
||||
private function syncReminder(int $tenantId, int $userId, Deal $deal, ParsedLeadRow $row): void
|
||||
{
|
||||
if ($row->reminderAt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Reminder::query()
|
||||
->where('deal_id', $deal->id)
|
||||
->where('remind_at', $row->reminderAt)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reminder::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'deal_id' => $deal->id,
|
||||
'text' => 'Импортировано из crm.bp-gr.ru',
|
||||
'remind_at' => $row->reminderAt,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* upsert import_unknown_statuses: инкремент occurrences, маппинг не трогаем.
|
||||
*
|
||||
|
||||
@@ -152,7 +152,7 @@ class LeadRegionResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
|
||||
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
|
||||
*/
|
||||
private function maskResponse(DaDataPhoneResponse $response): array
|
||||
{
|
||||
|
||||
@@ -93,22 +93,6 @@ class MonthlyPartitionManager
|
||||
{
|
||||
$this->assertKnownTable($table);
|
||||
|
||||
// migrate:fresh resilience: skip gracefully if the partitioned parent table
|
||||
// does not exist yet. The initial schema-load migration runs
|
||||
// partitions:create-months before later delta-migrations create their own
|
||||
// partitioned tables (project_routing_snapshots, lead_region_resolution_log);
|
||||
// those migrations create their own partitions. Skipping a not-yet-existing
|
||||
// parent here avoids crashing the whole run — and is a targeted guard, so any
|
||||
// other DDL error still surfaces.
|
||||
$parentExists = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
|
||||
[$table],
|
||||
);
|
||||
|
||||
if ($parentExists === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$partitionKey = self::PARTITIONED_TABLES[$table];
|
||||
$start = $monthStart->copy()->startOfMonth();
|
||||
$end = $start->copy()->addMonth();
|
||||
@@ -124,7 +108,16 @@ class MonthlyPartitionManager
|
||||
if ($exists !== null) {
|
||||
return false;
|
||||
}
|
||||
// Родитель-партиционированная таблица может ещё не существовать
|
||||
// (создаётся более поздней миграцией) — тогда пропускаем.
|
||||
$parentExists = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
|
||||
[$table],
|
||||
);
|
||||
|
||||
if ($parentExists === null) {
|
||||
return false;
|
||||
}
|
||||
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
|
||||
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
|
||||
$partition,
|
||||
|
||||
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\InvoicePaidNotification;
|
||||
use App\Mail\NewLeadsDigestMail;
|
||||
use App\Mail\NewLeadNotification;
|
||||
use App\Mail\ReminderDueNotification;
|
||||
use App\Mail\TopupSuccessNotification;
|
||||
use App\Mail\ZeroBalancePausedMail;
|
||||
use App\Models\Deal;
|
||||
use App\Models\InAppNotification;
|
||||
use App\Models\Project;
|
||||
use App\Models\Reminder;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -23,9 +25,9 @@ use Throwable;
|
||||
* Центральный диспетчер уведомлений (ТЗ §18.5, schema v8.9 §4 users.notification_preferences).
|
||||
*
|
||||
* Матрица 8 событий × 3 каналов (inapp / push / email) хранится в
|
||||
* `users.notification_preferences` JSONB. По умолчанию (канон — schema.sql §users
|
||||
* DEFAULT; G2-B 19.06 флипнул new_lead.email false→true — дайджест по умолчанию ВКЛ):
|
||||
* new_lead {inapp:true, push:true, email:true}
|
||||
* `users.notification_preferences` JSONB. По умолчанию (см. schema.sql:699):
|
||||
* new_lead {inapp:true, push:true, email:false}
|
||||
* reminder {inapp:true, push:true, email:true}
|
||||
* low_balance {email:true}
|
||||
* zero_balance {email:true}
|
||||
* topup_success {email:true}
|
||||
@@ -42,6 +44,8 @@ class NotificationService
|
||||
{
|
||||
public const EVENT_NEW_LEAD = 'new_lead';
|
||||
|
||||
public const EVENT_REMINDER = 'reminder';
|
||||
|
||||
public const EVENT_LOW_BALANCE = 'low_balance';
|
||||
|
||||
public const EVENT_ZERO_BALANCE = 'zero_balance';
|
||||
@@ -56,6 +60,7 @@ class NotificationService
|
||||
|
||||
public const ALL_EVENTS = [
|
||||
self::EVENT_NEW_LEAD,
|
||||
self::EVENT_REMINDER,
|
||||
self::EVENT_LOW_BALANCE,
|
||||
self::EVENT_ZERO_BALANCE,
|
||||
self::EVENT_TOPUP_SUCCESS,
|
||||
@@ -85,8 +90,11 @@ class NotificationService
|
||||
{
|
||||
$projectName = $deal->project?->name ?? 'Без проекта';
|
||||
|
||||
// Канал email события new_lead переведён на дайджест (SendNewLeadsDigestJob).
|
||||
// Здесь — только in-app (колокольчик) на каждую сделку.
|
||||
// Канал email.
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) {
|
||||
$this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadNotification($user, $deal, $tenant));
|
||||
}
|
||||
|
||||
// Канал inapp.
|
||||
$title = "Новый лид — {$projectName}";
|
||||
$body = $deal->contact_name ?: $deal->phone;
|
||||
@@ -99,14 +107,41 @@ class NotificationService
|
||||
}
|
||||
|
||||
/**
|
||||
* G2-A дайджест: одно письмо-сводка о новых сделках за окно — каждому
|
||||
* активному пользователю тенанта с включённым new_lead.email. Заменяет
|
||||
* пер-лид email-канал события new_lead.
|
||||
* Уведомление о наступлении срока напоминания. Получатели:
|
||||
* — assignee_id, если задан и активен;
|
||||
* — иначе created_by (создатель напоминания).
|
||||
*
|
||||
* Каналы: email + inapp по prefs пользователя. Триггер — Artisan
|
||||
* команда `reminders:dispatch-due`.
|
||||
*/
|
||||
public function notifyNewLeadsDigest(Tenant $tenant, Collection $deals): void
|
||||
public function notifyReminder(Reminder $reminder): void
|
||||
{
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) {
|
||||
$this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadsDigestMail($user, $tenant, $deals));
|
||||
$recipientId = $reminder->assignee_id ?? $reminder->created_by;
|
||||
$recipient = User::query()
|
||||
->where('id', $recipientId)
|
||||
->where('tenant_id', $reminder->tenant_id)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($recipient === null) {
|
||||
// Получатель удалён/деактивирован — некому слать.
|
||||
return;
|
||||
}
|
||||
|
||||
$shortText = $reminder->text ? mb_substr($reminder->text, 0, 80) : 'Срок касания клиента';
|
||||
$title = "Напоминание — {$shortText}";
|
||||
$body = 'Сделка #'.$reminder->deal_id;
|
||||
|
||||
if ($this->prefEnabled($recipient, self::EVENT_REMINDER, self::CHANNEL_EMAIL)) {
|
||||
$this->sendEmail($recipient, self::EVENT_REMINDER, new ReminderDueNotification($recipient, $reminder));
|
||||
}
|
||||
|
||||
if ($this->prefEnabled($recipient, self::EVENT_REMINDER, self::CHANNEL_INAPP)) {
|
||||
$this->notifyInApp($recipient, self::EVENT_REMINDER, $title, $body, [
|
||||
'reminder_id' => $reminder->id,
|
||||
'deal_id' => $reminder->deal_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use App\Mail\ImpersonationEndedMail;
|
||||
use App\Models\ImpersonationToken;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Завершение impersonation-сессии (G7-B / Ю-1).
|
||||
*
|
||||
* Выделено из middleware ImpersonationContext: слой Middleware не должен зависеть
|
||||
* от слоя Mail (deptrac ruleset). Отправку письма делает Service-слой, которому
|
||||
* зависимость на Mail разрешена.
|
||||
*/
|
||||
final class ImpersonationExpiryService
|
||||
{
|
||||
/**
|
||||
* Завершает активную сессию: ставит session_ended_at и шлёт клиенту письмо.
|
||||
* Идемпотентно — уже завершённая сессия не трогается и письмо не дублируется.
|
||||
*/
|
||||
public function endSession(ImpersonationToken $token): void
|
||||
{
|
||||
if ($token->session_ended_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token->update(['session_ended_at' => now()]);
|
||||
|
||||
try {
|
||||
Mail::to((string) $token->sent_to_email)
|
||||
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation expiry mail: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,56 +135,45 @@ class PdErasureService
|
||||
$counts['leads'] = $leads->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. deals (скалярный phone + JSONB phones — доп. телефоны субъекта)
|
||||
// Email в deals не хранится (нет колонки и нет JSONB-поля с email),
|
||||
// поэтому erasure только по email для сделок — корректный no-op:
|
||||
// сопоставлять нечего, counts['deals'] остаётся 0 (F-P1b, решение 2).
|
||||
// Сделки партиционированы — UPDATE по id на parent работает на PG 11+.
|
||||
// 3. deals (phone + contact_name)
|
||||
// Deals партиционированы — UPDATE без WHERE на партиции через
|
||||
// parent table работает начиная с PG 11+.
|
||||
// ------------------------------------------------------------------
|
||||
if ($phone !== null) {
|
||||
$dealQuery = DB::connection(self::DB)->table('deals');
|
||||
$dealQuery->where(function ($q) use ($phone): void {
|
||||
$q->where('phone', $phone)
|
||||
->orWhereRaw('phones @> ?::jsonb', [json_encode([$phone])]);
|
||||
});
|
||||
if ($tenantId !== null) {
|
||||
$dealQuery->where('tenant_id', $tenantId);
|
||||
$dealQuery = DB::connection(self::DB)->table('deals');
|
||||
$dealQuery->where(function ($q) use ($email, $phone): void {
|
||||
if ($phone !== null) {
|
||||
$q->orWhere('phone', $phone);
|
||||
}
|
||||
|
||||
$deals = $dealQuery->get(['id', 'phone', 'phones']);
|
||||
|
||||
// Хирургическое удаление телефона субъекта из JSONB-массива доп.
|
||||
// телефонов: остаются все элементы, не равные $phone; пустой
|
||||
// результат -> NULL. COALESCE — NULL-безопасность; порядок
|
||||
// со-контактов сохраняется (152-ФЗ: чужие телефоны не трогаем).
|
||||
$phonesScrubExpr = '(SELECT JSONB_AGG(elem) '
|
||||
."FROM JSONB_ARRAY_ELEMENTS(COALESCE(phones, '[]'::jsonb)) AS elem "
|
||||
.'WHERE elem <> TO_JSONB(?::text))';
|
||||
|
||||
foreach ($deals as $deal) {
|
||||
$dealId = (int) $deal->id;
|
||||
|
||||
if ($deal->phone === $phone) {
|
||||
// Субъект — владелец сделки: анонимизируем скалярные ПДн
|
||||
// и хирургически чистим массив доп. телефонов.
|
||||
DB::connection(self::DB)->update(
|
||||
'UPDATE deals SET phone = ?, contact_name = ?, '
|
||||
."phones = {$phonesScrubExpr}, updated_at = ? WHERE id = ?",
|
||||
['+7000XXXXXXX', 'Удалено', $phone, $now, $dealId],
|
||||
);
|
||||
} else {
|
||||
// Субъект — лишь доп. телефон чужой сделки: скалярные
|
||||
// phone/contact_name владельца НЕ трогаем, чистим только массив.
|
||||
DB::connection(self::DB)->update(
|
||||
"UPDATE deals SET phones = {$phonesScrubExpr}, "
|
||||
.'updated_at = ? WHERE id = ?',
|
||||
[$phone, $now, $dealId],
|
||||
);
|
||||
}
|
||||
if ($email !== null) {
|
||||
// Дополнительно: UTM/phones JSONB может хранить email, но в
|
||||
// минимуме ищем только по phone. Email в deals не хранится
|
||||
// в отдельной колонке.
|
||||
}
|
||||
|
||||
$counts['deals'] = $deals->count();
|
||||
});
|
||||
if ($tenantId !== null) {
|
||||
$dealQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
// Исключаем строки без совпадения по phone (когда phone=null — ничего не ищем)
|
||||
if ($phone === null) {
|
||||
// deals не имеет email-колонки, пропускаем
|
||||
$dealQuery->whereRaw('FALSE');
|
||||
}
|
||||
|
||||
$deals = $dealQuery->get(['id']);
|
||||
|
||||
foreach ($deals as $deal) {
|
||||
$dealId = (int) $deal->id;
|
||||
|
||||
DB::connection(self::DB)->table('deals')
|
||||
->where('id', $dealId)
|
||||
->update([
|
||||
'phone' => '+7000XXXXXXX',
|
||||
'contact_name' => 'Удалено',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$counts['deals'] = $deals->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Обновить pd_subject_requests если requestId передан
|
||||
|
||||
@@ -4,15 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Formatters;
|
||||
|
||||
use App\Support\CsvFormulaGuard;
|
||||
|
||||
/**
|
||||
* Excel-friendly CSV: BOM (U+FEFF), ; разделитель, \r\n EOL, escape двойными
|
||||
* кавычками для значений с ; / " / \n. Совместимость с MS Excel ru-RU без
|
||||
* импортного wizard'а — открывается двойным кликом.
|
||||
*
|
||||
* F-CSV: ячейки данных нейтрализуются от formula-инъекции (CsvFormulaGuard) —
|
||||
* единый писатель для всех типов отчётов. Числа не трогаются.
|
||||
*/
|
||||
class CsvFormatter implements ReportFormatter
|
||||
{
|
||||
@@ -21,7 +16,7 @@ class CsvFormatter implements ReportFormatter
|
||||
$lines = ["\u{FEFF}".implode(';', array_map([$this, 'escape'], $headers))];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$cells = array_map(fn ($v) => $this->escape(CsvFormulaGuard::neutralizeCell($v)), $row);
|
||||
$cells = array_map(fn ($v) => $this->escape($v === null ? '' : (string) $v), $row);
|
||||
$lines[] = implode(';', $cells);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Reports\Formatters;
|
||||
|
||||
use App\Support\CsvFormulaGuard;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
@@ -16,10 +14,6 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
* Quirk PhpSpreadsheet 5.x: метод setCellValueByColumnAndRow($col, $row, $val)
|
||||
* удалён (deprecated в 4.x → removed в 5.x). Используем A1-нотацию через
|
||||
* Coordinate::stringFromColumnIndex.
|
||||
*
|
||||
* F-CSV: опасные строки (ведущий = + - @) пишутся как явный текст
|
||||
* (setCellValueExplicit TYPE_STRING) — Excel НЕ вычисляет формулу. Числа
|
||||
* остаются числами.
|
||||
*/
|
||||
class XlsxFormatter implements ReportFormatter
|
||||
{
|
||||
@@ -43,12 +37,7 @@ class XlsxFormatter implements ReportFormatter
|
||||
foreach ($rows as $rowIdx => $row) {
|
||||
foreach ($row as $colIdx => $value) {
|
||||
$cell = $this->cellAddress($colIdx, $rowIdx + 2);
|
||||
if (CsvFormulaGuard::isDangerous($value)) {
|
||||
// formula-инъекция: пишем как явный текст, Excel не вычислит.
|
||||
$sheet->setCellValueExplicit($cell, (string) $value, DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue($cell, $value);
|
||||
}
|
||||
$sheet->setCellValue($cell, $value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,9 +70,6 @@ class DealsExportProvider implements ReportDataProvider
|
||||
$managerName = $row->user_email ?? '';
|
||||
}
|
||||
|
||||
// Провайдер отдаёт СЫРЫЕ данные. Защита от CSV/formula-инъекции —
|
||||
// в писателях-форматтерах (CsvFormatter/XlsxFormatter), чтобы не
|
||||
// портить не-табличные форматы (JSON). См. F-CSV.
|
||||
return [
|
||||
(int) $row->id,
|
||||
(string) ($row->phone ?? ''),
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Requisites;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantRequisites;
|
||||
use App\Support\PhoneNormalizer;
|
||||
|
||||
final class RequisitesService
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $data валидированный payload (телефон ещё в сыром виде)
|
||||
*/
|
||||
public function upsert(Tenant $tenant, array $data): TenantRequisites
|
||||
{
|
||||
if (isset($data['contact_phone'])) {
|
||||
$data['contact_phone'] = PhoneNormalizer::normalize((string) $data['contact_phone']);
|
||||
}
|
||||
|
||||
$req = TenantRequisites::firstOrNew(['tenant_id' => $tenant->id]);
|
||||
$req->fill($data);
|
||||
$req->tenant_id = $tenant->id;
|
||||
$req->requisites_completed_at = filled($req->bank_account) ? now() : null;
|
||||
$req->save();
|
||||
|
||||
return $req;
|
||||
}
|
||||
|
||||
public function isLightComplete(Tenant $tenant): bool
|
||||
{
|
||||
$r = TenantRequisites::where('tenant_id', $tenant->id)->first();
|
||||
if ($r === null) {
|
||||
return false;
|
||||
}
|
||||
if (blank($r->subject_type) || blank($r->contact_name) || blank($r->contact_phone)) {
|
||||
return false;
|
||||
}
|
||||
if (in_array($r->subject_type, ['legal_entity', 'sole_proprietor'], true) && blank($r->inn)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
/**
|
||||
* Трекинг активных сессий пользователя для вкладки «Безопасность»
|
||||
* (UI-аудит 21.06.2026). Использует существующую таблицу user_sessions.
|
||||
*
|
||||
* Запись при входе; отзыв = удаление строки + удаление сессии из Redis по
|
||||
* session_id (после этого следующий запрос с устройства разлогинится).
|
||||
*
|
||||
* NB: в token_hash хранится сам session_id (нужен для удаления из Redis) —
|
||||
* как делает database-драйвер сессий Laravel (PK = raw session id). БД защищена
|
||||
* ролями/RLS-периметром; сессии короткоживущие (expires_at).
|
||||
*
|
||||
* Best-effort: ошибки трекинга НЕ ломают вход/выход (только лог).
|
||||
*/
|
||||
class UserSessionTracker
|
||||
{
|
||||
/** Записать/обновить запись текущей сессии после успешного входа. */
|
||||
public function record(Request $request, int $userId): void
|
||||
{
|
||||
try {
|
||||
$sid = $request->session()->getId();
|
||||
DB::table('user_sessions')->updateOrInsert(
|
||||
['token_hash' => $sid],
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => substr((string) $request->userAgent(), 0, 1000),
|
||||
'last_active_at' => now(),
|
||||
'created_at' => now(),
|
||||
'expires_at' => now()->addMinutes((int) config('session.lifetime', 120)),
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('user_session.record_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отозвать сессию пользователя: удалить из Redis (реальный выход) + строку.
|
||||
* Возвращает true, если строка принадлежит юзеру и отозвана.
|
||||
*/
|
||||
public function revoke(int $userId, int $sessionRowId): bool
|
||||
{
|
||||
$row = DB::table('user_sessions')
|
||||
->where('id', $sessionRowId)
|
||||
->where('user_id', $userId)
|
||||
->first(['id', 'token_hash']);
|
||||
|
||||
if ($row === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Redis::connection(config('session.connection') ?: null)->del($row->token_hash);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('user_session.redis_del_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
DB::table('user_sessions')->where('id', $row->id)->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Убрать текущую сессию из списка при logout. */
|
||||
public function revokeCurrent(Request $request): void
|
||||
{
|
||||
try {
|
||||
DB::table('user_sessions')
|
||||
->where('token_hash', $request->session()->getId())
|
||||
->delete();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('user_session.revoke_current_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Защита от CSV/formula-инъекции (OWASP «Formula Injection»).
|
||||
*
|
||||
* Ячейка выгрузки, начинающаяся с = + - @ (а также TAB/CR), при открытии файла
|
||||
* в Excel/LibreOffice трактуется как формула и может исполниться
|
||||
* (`=HYPERLINK(...)`, `=cmd|...`). Нейтрализация — префикс апострофом: ячейка
|
||||
* показывается как текст, формула не исполняется.
|
||||
*
|
||||
* Применять ТОЛЬКО к свободному тексту (комментарии, контакты, имена, телефон) —
|
||||
* НЕ к числовым колонкам, где ведущий `-` — это легитимный знак минуса.
|
||||
*/
|
||||
final class CsvFormulaGuard
|
||||
{
|
||||
/** Символы-триггеры формул Excel/LibreOffice. */
|
||||
private const TRIGGERS = ['=', '+', '-', '@', "\t", "\r"];
|
||||
|
||||
/**
|
||||
* Нейтрализует formula-инъекцию в строковом значении. null/пустую строку и
|
||||
* безопасные значения возвращает без изменений.
|
||||
*/
|
||||
public static function neutralize(?string $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return in_array($value[0], self::TRIGGERS, true) ? "'".$value : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Опасна ли ячейка произвольного типа? true только для НЕ-числовой строки,
|
||||
* начинающейся с формульного символа. Числа (int/float и числовые строки,
|
||||
* где ведущий `-`/`+` — знак) безопасны.
|
||||
*/
|
||||
public static function isDangerous(mixed $value): bool
|
||||
{
|
||||
if (! is_string($value) || $value === '' || is_numeric($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($value[0], self::TRIGGERS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нейтрализует ячейку отчёта произвольного типа (для писателей-форматтеров,
|
||||
* где колонки смешанные). Числа не трогает, опасный текст префиксует
|
||||
* апострофом, null → пустая строка.
|
||||
*/
|
||||
public static function neutralizeCell(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::isDangerous($value) ? "'".$value : (string) $value;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
# Imitation Harness — выверенные сигнатуры (Task 0)
|
||||
|
||||
Опорный файл для субагентов Фазы 1. Все сигнатуры подтверждены чтением `origin/main`
|
||||
(базовый коммит `bd7b1d3e`). НЕ угадывать — сверяться отсюда.
|
||||
|
||||
Спек: `docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md`
|
||||
План: `docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md`
|
||||
|
||||
## ⚠️ КРИТИЧЕСКИЕ ПРАВКИ К ПЛАНУ (иначе ghost'ы)
|
||||
|
||||
1. **Коды субъектов — порядковые 1..89, НЕ коды ГИБДД.** В `App\Support\RussianRegions::CODE_TO_NAME`:
|
||||
`82 => 'Москва'`, `83 => 'Санкт-Петербург'`, `77 => 'Тюменская область'`.
|
||||
В тестах/сеялке использовать коды ТОЛЬКО через `RussianRegions::CODE_TO_NAME` /
|
||||
`RussianRegions::nameToCode()`. Пример в плане «77=Москва» — НЕВЕРЕН, Москва = **82**.
|
||||
2. **`DaDataPhoneResponse`** — `final readonly`, конструктор 9 аргументов:
|
||||
`(?int $qc, ?int $qcConflict, ?string $type, ?string $phone, ?string $provider, ?string $region, ?string $city, ?string $timezone, array $raw)`.
|
||||
3. **`DaDataPhoneClient`** — НЕ final, `__construct(private readonly HttpFactory $http)`,
|
||||
метод `cleanPhone(string $phone): DaDataPhoneResponse`. Фейк — наследник с переопределённым
|
||||
конструктором (без вызова parent) + `cleanPhone`; биндить `app()->instance(DaDataPhoneClient::class, $fake)`.
|
||||
4. **Снапшот в Pest-тестах** — НЕ через job (он строит только `tomorrow`). Использовать
|
||||
существующие Pest-хелперы из `app/tests/Pest.php`: `createRoutingSnapshotFromProject(...)`
|
||||
и `linkProjectToSupplier(Project, SupplierProject)`. Для живого портала (Task 14) —
|
||||
`php artisan snapshot:rebuild --date=<activeDate>` (DELETE+INSERT, детерминированно).
|
||||
5. **`ProjectFactory`** — есть хелперы `asSiteSignal(string $domain)`, `asCallSignal(string $phone)`.
|
||||
`regions` по умолчанию `'{}'` — задавать явно (массив кодов 1..89). `region_mask`/`region_mode` — legacy.
|
||||
6. **`TenantFactory`** НЕ задаёт `balance_rub` (default 0) и `frozen_by_balance_at` (null) — задавать
|
||||
через `ConditionLevers`.
|
||||
7. **`deals`** (v8.40) имеет `subject_code` (SMALLINT), `phone_operator` (TEXT),
|
||||
`region_substituted` (BOOLEAN default FALSE) — на них опирается X1.
|
||||
8. **RLS в тестах**: паттерн из `RlsSmokeTest` — `SET LOCAL ROLE testing_rls_user` +
|
||||
`SET LOCAL app.current_tenant_id`. Sharing-flow роутинга идёт через `pgsql_supplier`
|
||||
(BYPASSRLS) — следовать существующим slepok/supplier feature-тестам.
|
||||
9. **Деградация DaData**: фейк бросает `App\Services\DaData\DaDataException` (extends RuntimeException) —
|
||||
резолвер ловит её и уходит на Россвязь (ветка qc=1 / таймаут / 5xx).
|
||||
10. `SupplierLeadFactory` существует (поля: supplier_project_id, platform, raw_payload, vid, phone,
|
||||
received_at, source, processed_at, deals_created_count, error).
|
||||
|
||||
## Сигнатуры (verbatim)
|
||||
|
||||
### RegionResolution (`app/app/Services/Dto/RegionResolution.php`)
|
||||
```php
|
||||
final readonly class RegionResolution {
|
||||
public function __construct(
|
||||
public ?int $subjectCode, public ?int $actualSubjectCode, public string $source,
|
||||
public ?string $phoneOperator, public ?int $qc, public bool $cacheHit,
|
||||
public ?array $dadataResponseMasked, public ?int $durationMs, public bool $rossvyazMatched,
|
||||
) {}
|
||||
public static function make(?int $subjectCode, string $source, ?string $operator=null, ?int $qc=null,
|
||||
bool $cacheHit=false, ?array $dadataMasked=null, ?int $durationMs=null, bool $rossvyazMatched=false): self
|
||||
public static function fromSupplierLead(SupplierLead $lead): self
|
||||
public static function fromTag(?int $subjectCode): self
|
||||
public function withCacheHit(bool $hit): self
|
||||
public function forCache(): self
|
||||
}
|
||||
```
|
||||
|
||||
### RossvyazPrefixLookup / RossvyazRecord
|
||||
```php
|
||||
public function find(string $phone): ?RossvyazRecord
|
||||
final readonly class RossvyazRecord { public function __construct(public ?int $subjectCode, public string $region, public string $operator) {} }
|
||||
```
|
||||
|
||||
### LeadRouter (прод)
|
||||
`matchEligibleProjects(SupplierProject $sp, ?int $resolvedSubjectCode = null): Collection<Project>`
|
||||
— 3-фазный каскад (exact→all_ru→any), `routing_step` 1/2/3, взвешенный жребий (вес = остаток лимита, ≥1), cap=3. Конструктор: `__construct(private readonly Randomizer $randomizer = new Randomizer)` — в тестах инъектировать сиданный `new Randomizer(new Mt19937(seed))`.
|
||||
|
||||
### Снапшот
|
||||
- `project_routing_snapshots` колонки: snapshot_date, project_id, tenant_id, daily_limit, delivery_days_mask, regions (int[] default '{}'), signal_type, signal_identifier, sms_senders, sms_keyword, expected_volume, delivered_count, created_at. PK (snapshot_date, project_id). Партиц. по snapshot_date. RLS.
|
||||
- `snapshot:backfill --date=YYYY-MM-DD` (idempotent ON CONFLICT DO NOTHING); `snapshot:rebuild --date=YYYY-MM-DD` (DELETE+INSERT).
|
||||
|
||||
### Деньги (`LedgerService::chargeForDelivery(Tenant, Deal, ?SupplierLead): ChargeResult`)
|
||||
always-rub; тариф по `delivered_in_month+1`; `frozen_by_balance_at` → InsufficientBalance; bcmath `balance_rub*100 ≥ price`; пишет lead_charges (charge_source='rub') + balance_transactions + supplier_lead_costs.
|
||||
|
||||
### Приём (`POST /api/webhook/supplier/{secret}`)
|
||||
secret ≥32 (`system_settings.supplier_webhook_secret`, hash_equals); IP allowlist (testing fail-open);
|
||||
rate-limit 600/мин/IP; time в ±24ч; phone `^7\d{10}$`; vid UNIQUE → дубль 200 «already_processed»; project без `B[123]_` → DIRECT.
|
||||
|
||||
### Тест-бутстрап
|
||||
`TestCase::setUp` переводит redis cache в array. Feature-тесты: `pest()->extend(TestCase::class)->in('Feature')`. Хелперы `createRoutingSnapshotFromProject()` / `linkProjectToSupplier()` в `Pest.php`.
|
||||
|
||||
## Открытые мелочи
|
||||
- `DaDataTimeoutException` используется в клиенте, но как отдельный класс не подтверждён — для деградации в тестах бросать базовый `DaDataException`.
|
||||
- Все тесты Фазы 1 — группа `imitation` (`->group('imitation')`), вне `composer test`.
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class InnValidator
|
||||
{
|
||||
public static function isValid(string $inn, string $subjectType): bool
|
||||
{
|
||||
if ($subjectType === 'individual') {
|
||||
return true; // физлицу ИНН не требуется (SP2)
|
||||
}
|
||||
if (! ctype_digit($inn)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($subjectType) {
|
||||
'legal_entity' => self::valid10($inn),
|
||||
'sole_proprietor' => self::valid12($inn),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static function valid10(string $inn): bool
|
||||
{
|
||||
if (strlen($inn) !== 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::checksum($inn, [2, 4, 10, 3, 5, 9, 4, 6, 8]) === (int) $inn[9];
|
||||
}
|
||||
|
||||
private static function valid12(string $inn): bool
|
||||
{
|
||||
if (strlen($inn) !== 12) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::checksum($inn, [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) === (int) $inn[10]
|
||||
&& self::checksum($inn, [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]) === (int) $inn[11];
|
||||
}
|
||||
|
||||
/** @param int[] $weights */
|
||||
private static function checksum(string $inn, array $weights): int
|
||||
{
|
||||
$sum = 0;
|
||||
foreach ($weights as $i => $w) {
|
||||
$sum += $w * (int) $inn[$i];
|
||||
}
|
||||
|
||||
return ($sum % 11) % 10;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class PhoneNormalizer
|
||||
{
|
||||
/**
|
||||
* Нормализует российский номер к виду +7XXXXXXXXXX (12 символов) или null, если невалиден.
|
||||
*/
|
||||
public static function normalize(string $raw): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
|
||||
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
|
||||
$digits = '7'.substr($digits, 1);
|
||||
} elseif (strlen($digits) === 10) {
|
||||
$digits = '7'.$digits;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '+'.$digits;
|
||||
}
|
||||
}
|
||||
@@ -38,34 +38,6 @@ final class WebhookUrlGuard
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Один резолв хоста для доставки: причина блокировки И безопасный IP для
|
||||
* пиннинга соединения. Закрывает DNS-rebind TOCTOU — блок-решение и адрес
|
||||
* подключения берутся из ОДНОГО резолва, а не из двух независимых (как было
|
||||
* бы при blockReason + повторный резолв в HTTP-клиенте).
|
||||
*
|
||||
* @return array{ip: string|null, blockReason: string|null}
|
||||
*/
|
||||
public static function safeDeliveryIp(string $url): array
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return ['ip' => null, 'blockReason' => 'Некорректный URL webhook.'];
|
||||
}
|
||||
$host = trim($host, '[]');
|
||||
|
||||
$ips = self::resolve($host);
|
||||
foreach ($ips as $ip) {
|
||||
if (! self::isPublicIp($ip)) {
|
||||
return ['ip' => null, 'blockReason' => 'URL webhook ведёт во внутреннюю/зарезервированную сеть — запрещено.'];
|
||||
}
|
||||
}
|
||||
|
||||
// Все записи публичны (или хост не резолвится → ip=null, пиннинг не
|
||||
// применяется, запрос упадёт сам — как и в blockReason).
|
||||
return ['ip' => $ips[0] ?? null, 'blockReason' => null];
|
||||
}
|
||||
|
||||
/** @return list<string> Все IP, в которые разрешается хост (пусто, если не разрешается). */
|
||||
private static function resolve(string $host): array
|
||||
{
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\ApiKeyAuth;
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\ImpersonationContext;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -26,20 +24,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->alias([
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
'apikey' => ApiKeyAuth::class,
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
ImpersonationContext::class,
|
||||
]);
|
||||
|
||||
// Защитные HTTP-заголовки (CSP, X-Frame-Options, X-Content-Type-Options,
|
||||
// Referrer-Policy, HSTS, Permissions-Policy, COOP/CORP) ставит nginx —
|
||||
// единый источник: /etc/nginx/sites-available/liderra (add_header ... always).
|
||||
// App-уровневый middleware SecurityHeaders удалён 18.06.2026: он дублировал
|
||||
// те же заголовки, и на проде add_header always + PHP-заголовок давали дубль
|
||||
// в ответе. CSP в nginx — enforcing (был Report-Only в middleware).
|
||||
|
||||
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
|
||||
// CSRF — запросы приходят от внешних CRM-систем без сессии браузера.
|
||||
// Авторизация — через webhook_token в URL + (на prod) HMAC.
|
||||
|
||||
Generated
+177
-186
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
// Логины nginx HTTP Basic Auth (.htpasswd-admin), допущенные в saas-admin зону.
|
||||
// CSV из env; дефолт совпадает с прод-.htpasswd (единственный логин — admin).
|
||||
'basic_auth_allowlist' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('ADMIN_ALLOWED_USERS', 'admin')),
|
||||
))),
|
||||
|
||||
// Включение fail-closed гейта. В local/testing — выкл (nginx нет, тесты
|
||||
// аутентифицируются иначе); на проде/staging — вкл. Только env() — config
|
||||
// грузится до готовности контейнера, app()->environment() здесь падает.
|
||||
'basic_auth_gate' => (bool) env(
|
||||
'ADMIN_GATE_ENFORCED',
|
||||
! in_array(env('APP_ENV', 'production'), ['local', 'testing'], true),
|
||||
),
|
||||
|
||||
// Системный saas_admin_users.id для audit-trail admin-действий (FK
|
||||
// saas_admin_audit_log.admin_user_id). На проде рантайм-роль crm_app_user НЕ
|
||||
// имеет прав на saas_admin_users (админ-креды изолированы) → задаём id здесь,
|
||||
// чтобы не обращаться к таблице. null (dev/test, суперюзер) → fallback на
|
||||
// авто-создание стаба. На проде — ADMIN_AUDIT_SYSTEM_USER_ID=1 (сид-стаб).
|
||||
'audit_system_user_id' => env('ADMIN_AUDIT_SYSTEM_USER_ID') !== null
|
||||
? (int) env('ADMIN_AUDIT_SYSTEM_USER_ID')
|
||||
: null,
|
||||
];
|
||||
@@ -42,13 +42,6 @@ return [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
|
||||
// G7-B: guard для машинных ключей impersonation (lpimp_<id>_<secret>).
|
||||
// Driver «impersonation» регистрируется через Auth::viaRequest в AppServiceProvider::boot.
|
||||
// Используется в паре с auth:sanctum,impersonation на рабочих группах кабинета.
|
||||
'impersonation' => [
|
||||
'driver' => 'impersonation',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
+2
-12
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Logging\PiiScrubbingProcessor;
|
||||
use App\Logging\ScrubPii;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
@@ -65,8 +63,6 @@ return [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
// PII-scrubbing: маскирует телефоны/email в записях laravel.log.
|
||||
'tap' => [ScrubPii::class],
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
@@ -75,8 +71,6 @@ return [
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
// PII-scrubbing: маскирует телефоны/email в записях laravel.log.
|
||||
'tap' => [ScrubPii::class],
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
@@ -86,8 +80,6 @@ return [
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
// PII-scrubbing: маскирует телефоны/email в сообщениях в Slack.
|
||||
'tap' => [ScrubPii::class],
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
@@ -99,8 +91,7 @@ return [
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
// PII-scrubbing добавлен после PsrLogMessageProcessor.
|
||||
'processors' => [PsrLogMessageProcessor::class, PiiScrubbingProcessor::class],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
@@ -111,8 +102,7 @@ return [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
// PII-scrubbing добавлен после PsrLogMessageProcessor.
|
||||
'processors' => [PsrLogMessageProcessor::class, PiiScrubbingProcessor::class],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
|
||||
@@ -35,15 +35,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
// Капча самозаписи (G1/SP1). driver=null → NullCaptchaVerifier (dev/test).
|
||||
// Реальный Yandex SmartCaptcha подключается позже (SP3/ops).
|
||||
'captcha' => [
|
||||
'driver' => env('CAPTCHA_DRIVER', 'null'),
|
||||
'fake_passes' => filter_var(env('CAPTCHA_FAKE_PASSES', true), FILTER_VALIDATE_BOOL),
|
||||
'yandex_server_key' => env('YANDEX_SMARTCAPTCHA_SERVER_KEY'),
|
||||
'yandex_validate_url' => env('YANDEX_SMARTCAPTCHA_VALIDATE_URL', 'https://smartcaptcha.cloud.yandex.ru/validate'),
|
||||
],
|
||||
|
||||
'supplier' => [
|
||||
'login' => env('SUPPLIER_LOGIN'),
|
||||
'password' => env('SUPPLIER_PASSWORD'),
|
||||
@@ -62,17 +53,6 @@ return [
|
||||
'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу
|
||||
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
|
||||
// G1/SP2: подтяжка организации по ИНН (suggestions findById/party). Тот же api_key
|
||||
// (Token), secret не нужен. Default false → NullPartyLookup (dev/тесты не ходят в сеть).
|
||||
'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
|
||||
// G7-A: клиентская «Помощь».
|
||||
'support' => [
|
||||
'email' => env('SUPPORT_EMAIL', 'support@liderra.ru'),
|
||||
],
|
||||
'jivosite' => [
|
||||
'widget_id' => env('JIVO_WIDGET_ID'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Reminder;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @extends Factory<Reminder>
|
||||
*/
|
||||
class ReminderFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
// remind_at +1 час по умолчанию (in future, неотправлено).
|
||||
// Тесты для overdue/completed-flow явно ставят remind_at и completed_at.
|
||||
$tenant = Tenant::factory();
|
||||
|
||||
return [
|
||||
'tenant_id' => $tenant,
|
||||
'deal_id' => fake()->numberBetween(1, 999999), // deal_id без FK
|
||||
'text' => fake()->sentence(),
|
||||
'remind_at' => Carbon::now()->addHour(),
|
||||
'created_by' => User::factory()->state(fn (array $attrs, Reminder $r) => ['tenant_id' => $r->tenant_id]),
|
||||
'assignee_id' => null,
|
||||
'is_sent' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function overdue(): static
|
||||
{
|
||||
return $this->state(['remind_at' => Carbon::now()->subHours(2)]);
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state([
|
||||
'completed_at' => Carbon::now()->subMinutes(10),
|
||||
'remind_at' => Carbon::now()->subHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sent(): static
|
||||
{
|
||||
return $this->state([
|
||||
'is_sent' => true,
|
||||
'sent_at' => Carbon::now()->subMinute(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantRequisites;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -29,20 +28,4 @@ class TenantFactory extends Factory
|
||||
'api_key_limit' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* G1/SP2: тенант с заполненными лёгкими реквизитами — проходит гейт первого
|
||||
* проекта. Используется тестами, которые создают проекты у нового тенанта.
|
||||
*/
|
||||
public function withRequisites(): static
|
||||
{
|
||||
return $this->afterCreating(function (Tenant $tenant): void {
|
||||
TenantRequisites::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'subject_type' => 'individual',
|
||||
'contact_name' => 'Test Contact',
|
||||
'contact_phone' => '+79150000000',
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ class UserFactory extends Factory
|
||||
// строку после INSERT, поэтому колонки с DB-DEFAULT'ами видны как
|
||||
// null на свежесозданной модели — нужно явно задать здесь.
|
||||
'notification_preferences' => [
|
||||
'new_lead' => ['inapp' => true, 'push' => true, 'email' => true],
|
||||
'new_lead' => ['inapp' => true, 'push' => true, 'email' => false],
|
||||
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
|
||||
'low_balance' => ['email' => true],
|
||||
'zero_balance' => ['email' => true],
|
||||
'topup_success' => ['email' => true],
|
||||
|
||||
@@ -18,16 +18,7 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run OUTSIDE Laravel's migration transaction. This migration loads the full
|
||||
* schema via DB::unprepared(), then calls partitions:create-months which opens
|
||||
* a SECOND connection (pgsql_supplier) for partition DDL. That connection cannot
|
||||
* see uncommitted DDL from this migration's transaction (PostgreSQL READ
|
||||
* COMMITTED), so the schema must be committed first. This is a full schema
|
||||
* (re)build — no partial rollback is meaningful.
|
||||
*/
|
||||
public $withinTransaction = false;
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user