Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,55 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage5-projects.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
## Todos
- Task 1: complete (review clean; миграция TenantProjects применена, 535 PASS). Отчёт: task-1-report.md.
- [x] Task 1: Миграция TenantProjects/ProjectCards
- Task 2: complete (review clean; 535 PASS; стадии 1:1, порт по Self-Review 3). Отчёт: task-2-report.md.
- [x] Task 2: Модуль Projects — стадии + DTO + IProjectStore
- Task 2: complete (стадии 1:1, DTO-модели §4.3, IProjectStore, реестр-каркас; build 0/0, 535 PASS). Отчёт: task-2-report.md.
- Task 3: complete (build 0/0; 535 PASS; ProjectStore — 13 методов порта на TenantDbContext, DI в AddDealPersistence; dev-харнесс 48/48 на дефолтном тенанте + psql, схема очищена). Отчёт: task-3-report.md.
- Task 3: complete (review clean; 535 PASS; ProjectStore 13/13). Отчёт: task-3-report.md.
- [x] Task 3: EF-адаптер ProjectStore
- Task 4: complete (1 fix round: presence-aware PATCH budget:null + append-тест истории; 556 PASS). Отчёт: task-4-report.md.
- [x] Task 4: take + ProjectsService
- Task 5: complete (review clean; 567 PASS). Отчёт: task-5-report.md.
- [x] Task 5: Комментарии/ссылки
- Task 6: complete (build 0/0, 584 PASS — 567 + 17 новых; IFileStorage + Local/MinIO + FileKindDetector + compose minio; MinIO live-check пройден, deal-minio поднят). Отчёт: task-6-report.md.
- Task 6: complete (review clean с 1 Important-заметкой). Отчёт: task-6-report.md. **Ruling**: FileMeta остаётся и задействуется в T9 (download: Content-Length/Type через StatObject/FileInfo); адаптеры выровнять (Put с позиции 0; Minio GetAsync dispose при ошибке ≠ NotFound); цифры тестов в отчёте — косметика.
- [x] Task 6: IFileStorage + Local/MinIO + FileKindDetector + compose-minio
- Task 7: complete (review clean; 599 PASS). Отчёт: task-7-report.md.
- [x] Task 7: ProjectFilesService
- Task 8: complete (build 0/0; 599 PASS; curl 50/50; boot-заглушка /projects снята, /tg/status остаётся). Отчёт: task-8-report.md.
- Task 8: complete (review clean; 599 PASS; curl 50/50). Отчёт: task-8-report.md. Ruling: ProjectPatchRequest не создан (presence-aware Dictionary — прецедент settings), зафиксировано.
- [x] Task 8: Эндпоинты карточек + замена boot-заглушки /projects
- Task 9: complete (build 0/0; 602 PASS; curl 41/41 FAIL=0, локальный режим). Отчёт: task-9-report.md. Ruling T6 закрыт: IFileStorage.StatAsync → FileMeta (MinIO StatObject / Local FileInfo); download отдаёт Content-Length/Content-Type из дескриптора (локально MIME пуст → octet-stream 1:1 прототип).
- Task 9: complete (review clean; 602 PASS; curl 41/41; Ruling T6 закрыт). Отчёт: task-9-report.md.
- [x] Task 9: Файл-эндпоинты upload/download/delete
- Task 10: complete (build 0/0; 614 PASS — 602 + 12 ProjectReminderServiceTests; curl 32/32). Отчёт: task-10-report.md.
- [x] Task 10: Напоминания (сервис + эндпоинты)
- Task 10: complete (review clean; 614 PASS; curl 32/32). Отчёт: task-10-report.md.
- [x] Task 10: Напоминания (сервис + эндпоинты)
- Task 11: complete (build 0/0; 617 PASS — 614 + 3 AdminTickOrchestratorTests; curl 23/23; reminders + SSE reminder_due, Ruling 8 — toast не шлём). Отчёт: task-11-report.md.
- Task 11: complete (review clean; 617 PASS; curl 23/23). Отчёт: task-11-report.md.
- [x] Task 11: admin/tick reminders + SSE
- Task 12: complete (review clean; 620 PASS; curl 19/19). Отчёт: task-12-report.md.
- [x] Task 12: Фоновая проверка напоминаний (30 с)
- Task 12: complete (build 0/0; 620 PASS — 617 + 3 StorageTickSchedulerTests; curl 19/19: reminder_due фоновым 30-с циклом БЕЗ ручного tick, ReminderFired=t, disabled → очистка без событий). Отчёт: task-12-report.md.
- Task 13: complete (review pending; build 0/0; 620 PASS; curl-приёмка :5080 PASS=75 FAIL=0, финал этапа; код не менялся — доки/ledger обновлены, dev-БД и вложения очищены, deal-minio оставлен поднятым). Отчёт: task-13-report.md.
- [x] Task 13: Финал/сквозная приёмка
## Pre-flight scan (краткий)
| Пара | Производит/потребляет | Результат |
|---|---|---|
| T1 → T3 | миграция → EF-адаптер | Чисто |
| T2 → T3/T4 | DTO/порт → адаптер/сервис | Чисто |
| T4 → T8 | ProjectsService → эндпоинты; замена boot-заглушки /projects | Чисто (T8 правит BootStubEndpoints) |
| T4 | take: IKanjStore.GetCardAsync + MarkTakenAsync (Kanban) | Projects→Kanban порт — разрешено; обратного пути нет |
| T6 → T7 | IFileStorage → ProjectFilesService | Чисто |
| T5 → T8/T9 | комментарии/ссылки → эндпоинты | Чисто |
| T10 → T11/T12 | напоминания → tick + фон 30 с | Чисто |
| T11/T12 | StorageTickScheduler расширяется (reminders+SSE reminder_due) | Осторожно: не сломать этап-3/4 поведение |
| T12 | SSE reminder_due — фронт слушает | Форма {id,title,stage} |
## Task status
@@ -0,0 +1,48 @@
# Task 1 — «Миграция TenantProjects: таблица ProjectCards» — отчёт
Статус: **DONE** (build 0/0, тесты 535/535 PASS, миграция применена к dev-схеме дефолтного тенанта, psql-приёмка зелёная).
## Файлы
### Созданы — сущность (`src/core/Deal.Infrastructure/Persistence/Entities/`, 1 тип = 1 файл)
| Файл | Таблица | Ключевые поля (Ruling 1(а), db.py L103125) |
|---|---|---|
| `ProjectCardEntity.cs` | `ProjectCards` (= projects) | Id (text PK, `pr_`), Stage (`planned`), Local (bool), LeadId (text?, БЕЗ FK — «мягкая» ссылка на `Cards.Id`, конвенция DedupEntries Ruling 1 этапа 4), Title, Summary, StackJson (text), BudgetFrom/BudgetTo (double?), BudgetCur (пусто — бюджета нет), Contact, CommentsJson/LinksJson/FilesJson/HistoryJson (text; wire-формы элементов), TzText (text), ReminderAt (timestamptz?), ReminderFired (bool), CreatedAt, UpdatedAt |
Времена — `DateTimeOffset``timestamptz`. Nullable только по Ruling 1: `LeadId`, `ReminderAt`. CLR-инициализаторы переносят прототипные дефолты (`Stage="planned"`, JSON-массивы `"[]"`, `BudgetCur=""`, `ReminderFired=false`) — DB-дефолты `HasDefaultValue` НЕ заданы (конвенция этапа 3: EF опускает колонку при CLR-дефолте). Одна таблица, история/комментарии/файлы НЕ выносятся — план (Ruling 1) предписывает JSON-массивы в карточке, отдельных таблиц этап не добавляет.
### Создана — конфигурация (`src/core/Deal.Infrastructure/Persistence/`)
| Файл | Содержание |
|---|---|
| `ProjectCardConfiguration.cs` | `ToTable("ProjectCards")`, HasKey(Id), пять JSON-колонок `.HasColumnType("text")` (как `Cards.StackJson`), индексы: `IX_ProjectCards_Stage`, `IX_ProjectCards_UpdatedAt` `.IsDescending()` (DESC — сортировка списка), частичный UNIQUE `IX_ProjectCards_LeadId` `.HasFilter("\"LeadId\" IS NOT NULL")` (гонка take) |
### Изменены
- `Persistence/TenantDbContext.cs` — DbSet `ProjectCards` + `ApplyConfiguration(new ProjectCardConfiguration())` (без `ApplyConfigurationsFromAssembly`, паттерн этапов 1–4).
- `Migrations/TenantDb/20260906200342_TenantProjects.cs` (+ `.Designer.cs`, обновлён `TenantDbContextModelSnapshot.cs`) — миграция.
## Миграция и psql-приёмка
- Создана: `dotnet ef migrations add TenantProjects --context TenantDbContext --output-dir Migrations/TenantDb --project Deal.Infrastructure --startup-project Deal.Api` (из `src/core`; dotnet-ef 10.0.11).
- DDL без схемы (search_path): `CreateTable ProjectCards` + 3 индекса; PK `PK_ProjectCards (Id)`. SQL-скрипт подтвердил: `CREATE UNIQUE INDEX IX_ProjectCards_LeadId … WHERE "LeadId" IS NOT NULL`, `IX_ProjectCards_UpdatedAt … DESC`.
- Применение: краткий старт `Deal.Api``TenantProvisioningService` применил миграцию к схеме дефолтного тенанта (фоновые воркеры после старта тикали нормально).
psql (`tenant_00000000000000000000000000000001`):
- Таблица `ProjectCards` создана (+ существующие Boards/Cards/…; итого 11 таблиц).
- Индексы: `IX_ProjectCards_Stage` (btree), `IX_ProjectCards_UpdatedAt` (btree, `DESC`), UNIQUE `IX_ProjectCards_LeadId` (partial `WHERE ("LeadId" IS NOT NULL)`).
- Семантика частичного UNIQUE проверена транзакциями с самоочисткой: два NULL LeadId — вставка OK (INSERT 0 2); два одинаковых LeadId — `duplicate key value violates unique constraint "IX_ProjectCards_LeadId"`; остаточных строк нет.
- `__TenantMigrationsHistory` содержит `20260906200342_TenantProjects` (после InitialTenant/TenantKanban/TenantPipeline).
## Валидация
- `dotnet build Deal.sln`: Предупреждений 0, Ошибок 0.
- `dotnet test tests/Deal.Tests.Unit`: 535/535 PASS (MarkerTests в составе).
- Диагностики изменённых файлов — без ошибок/предупреждений.
## Отклонения и решения
- `descending: new bool[0]` в `CreateIndex` миграции — штатная сериализация `.IsDescending()` без аргументов; сгенерированный SQL содержит `DESC` (проверено `ef migrations script` и pg_indexes).
- DB-дефолты не заданы — конвенция этапа 3 (как Task 1 этапа 4); сущность хранит JSON как `text`, парсинг — на уровне адаптера (Task 3).
- Модуль `Deal.Modules.Projects` в Task 1 не задействован: сущность/конфигурация живут в Infrastructure (эталон CardEntity/CardConfiguration), миграция принадлежит `TenantDbContext`.
@@ -0,0 +1,243 @@
#!/usr/bin/env sh
# Task 10 curl-приёмка напоминаний /api/projects/{cardId}/reminder[/snooze] на :5080 (план Task 10
# L411-432, Ruling 3; projects_routes.py L189-211; projects.py L236-282; api-map L172-174, §4.6 L328).
# Сценарий: очистка ProjectCards + settings.remindersEnabled -> запуск Deal.Api (Development, DEAL_DEMO=1)
# -> 401 без куки (set/delete/snooze) -> login admin/admin -> 404 на несуществующей карточке (set/delete/
# snooze), 400 set без поля at -> локальная карточка -> move hold -> POST reminder {at: now+1 мин} ->
# карточка с reminder.at -> DELETE reminder {ok:true}, reminder null -> снова set -> snooze -> reminder.at ~
# now+24 ч -> move ready: reminder сброшен -> move hold + set -> PATCH settings remindersEnabled=false ->
# set -> 400 «Напоминания об отложенных выключены в настройках» -> DELETE ok (выключатель не проверяет) ->
# PATCH settings restore true -> logout -> 401. Очистка строк/настроек после приёмки.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' не
# конвертируются MSYS-рантаймом; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
TMPB=$(cygpath -m /tmp)/task10
JAR="$TMPB/jar.txt"
OUT="$TMPB/out.txt"
HDR="$TMPB/hdr.txt"
HDRN="$TMPB/hdrn.txt"
LOG="$TMPB/api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
DAY_MS=86400000
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
PRJ=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
# Числовая проверка: значение поля reminder.at в диапазоне [min..max] ($OUT — GET карточки).
check_reminder_at_range() {
desc=$1
min=$2
max=$3
at=$(sed -n '1{s/.*"reminder":{"at":\([0-9][0-9]*\)}.*/\1/p}' "$OUT")
if [ -n "$at" ] && [ "$at" -ge "$min" ] 2>/dev/null && [ "$at" -le "$max" ] 2>/dev/null; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc ($at в [$min..$max])"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — reminder.at=$at вне [$min..$max] или не найден"
echo "--- ответ:"
cat "$OUT"
fi
}
# Первый id (pr_) из JSON-тела ответа.
extract_id() {
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
stop_app() {
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка Api и очистка созданных строк/настроек =="
stop_app "$APP_PID"
if [ -n "$PRJ" ]; then
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
fi
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
rm -rf "$TMPB"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$HDR" "$HDRN" "$LOG"
mkdir -p "$TMPB"
echo "== 0. Очистка ProjectCards и настройки remindersEnabled дефолтного тенанта (повторяемость) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] ProjectCards пусты, remindersEnabled — дефолт (true)"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
grep -q 'LocalFileStorage' "$LOG"
if [ $? = 0 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
head -n 3 "$LOG"
fi
echo " health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. 401 без сессии на reminder-эндпоинтах =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"at":1760000000000}' "$BASE_URL/api/projects/pr_x/reminder" > "$OUT"
check "POST reminder без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/pr_x/reminder" > "$OUT"
check "DELETE reminder без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/pr_x/reminder/snooze" > "$OUT"
check "POST snooze без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 3. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 4. Несуществующая карточка: 404 на set/delete/snooze, 400 set без at =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"at":1760000000000}' "$BASE_URL/api/projects/pr_dead/reminder" > "$OUT"
check "POST reminder на pr_dead → 404" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/pr_dead/reminder" > "$OUT"
check "DELETE reminder на pr_dead → 404" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/pr_dead/reminder/snooze" > "$OUT"
check "POST snooze на pr_dead → 404" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{}' "$BASE_URL/api/projects/pr_dead/reminder" > "$OUT"
check "POST reminder {} → 400 (нет at)" '[HTTP:400]' 'Поле at (epoch-ms) обязательно'
echo
echo "== 5. Локальная карточка и перенос в hold =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
PRJ=$(extract_id)
echo " -> PRJ: $PRJ"
if [ -z "$PRJ" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
check "move hold 200" '[HTTP:200]' '"stage":"hold"'
echo
echo "== 6. POST reminder {at: now+1 мин} → карточка с reminder.at =="
AT1=$(( $(date +%s) * 1000 + 60000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "POST reminder 200 — карточка" '[HTTP:200]' '"reminder":{"at":'"$AT1"'}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "GET карточки — reminder на месте" '[HTTP:200]' '"reminder":{"at":'"$AT1"'}' '"stage":"hold"'
echo
echo "== 7. DELETE reminder → {ok:true}, карточка без reminder =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "DELETE reminder 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "GET карточки — reminder null" '[HTTP:200]' '"reminder":null'
echo
echo "== 8. Повторный set и snooze → reminder.at ~ now+24 ч =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "POST reminder снова 200" '[HTTP:200]' '"reminder":{"at":'"$AT1"'}'
SNOOZE_BEFORE=$(( $(date +%s) * 1000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/$PRJ/reminder/snooze" > "$OUT"
check "POST snooze 200" '[HTTP:200]' '"ok":true'
SNOOZE_AFTER=$(( $(date +%s) * 1000 + 2000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "GET карточки после snooze 200" '[HTTP:200]' '"reminder":{'
check_reminder_at_range "reminder.at после snooze = now+24 ч (±2 с)" $((SNOOZE_BEFORE + DAY_MS)) $((SNOOZE_AFTER + DAY_MS))
echo
echo "== 9. Move hold → ready: напоминание сброшено (Ruling 3) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"ready"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
check "move ready 200" '[HTTP:200]' '"stage":"ready"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "GET карточки после move — reminder null" '[HTTP:200]' '"reminder":null'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
check "move обратно в hold 200" '[HTTP:200]' '"stage":"hold"'
echo
echo "== 10. Выключенные напоминания: set → 400, delete работает =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":false}' "$BASE_URL/api/settings" > "$OUT"
check "PATCH settings remindersEnabled=false 200" '[HTTP:200]' '"remindersEnabled":false'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
check "GET settings — remindersEnabled false" '[HTTP:200]' '"remindersEnabled":false'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "POST reminder при выключенных → 400" '[HTTP:400]' 'Напоминания об отложенных выключены в настройках'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/$PRJ/reminder/snooze" > "$OUT"
check "snooze при выключенных → 200 (выключатель не проверяет)" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "DELETE reminder при выключенных → 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":true}' "$BASE_URL/api/settings" > "$OUT"
check "PATCH settings restore true 200" '[HTTP:200]' '"remindersEnabled":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
check "GET settings — remindersEnabled true" '[HTTP:200]' '"remindersEnabled":true'
echo
echo "== 11. Logout → 401 на reminder =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT1}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "POST reminder после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
exit 1
fi
@@ -0,0 +1,88 @@
# Task 10 — Напоминания «Отложено»: ProjectReminderService + эндпоинты reminder/reminder/snooze — отчёт
Статус: **DONE** (build 0/0; 614/614 PASS — 602 этапов 19 + 12 новых ProjectReminderServiceTests;
curl-приёмка :5080 — **32/32 PASS**). План:
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 10 (L411432), Ruling 3 (напоминания:
окно — фронт HoldReminderDialog; бэкенд хранит ReminderAt; любой move сбрасывает; remindersEnabled —
общий публичный ключ SettingsKeys.RemindersEnabled, дефолт true; set → 400 при выключенном, clear/snooze
выключатель не проверяют; CheckDueAsync — disabled→очистка протухших / enabled→due-fired); источники
`backend/app/services/projects.py` L236282, `backend/app/routers/projects_routes.py` L189211, api-map
L172174/§4.6 L328, §3.5 L172; фронт store.js L20602145 (`{at}` epoch-ms; snooze без тела; ответ set —
карточка), HoldReminderDialog.vue.
## Файлы
### Создан
- `Deal.Modules.Projects/Application/ProjectReminderService.cs` — чистый сервис модуля (Ruling 2/3), DI —
IProjectStore + ISettingsStore:
- `SetAsync(cardId, atMs, ct) → ProjectCardResultDto` — карточки нет → 404-результат (ДО выключателя:
роутер `_card_or_404` L193 до вызова set_reminder); `remindersEnabled=false` → 400-результат
`<RemindersDisabledDetail>` «Напоминания об отложенных выключены в настройках» (L237–238);
иначе `store.SetReminderAsync` (reminder_at + fired=false + bump UpdatedAt, L239242) и возврат полной
карточки (return get_card L243). Стадия и время НЕ проверяются (1:1: фронт шлёт только для hold; at в
прошлом допустим — приёмка Tasks 11/13 «выстреливает» его ручным тиком).
- `ClearAsync(cardId, ct) → bool` — карточки нет → false (404); снятие через store (fired=false, без
bump — 1:1 L246247); выключатель не проверяется.
- `SnoozeAsync(cardId, ct) → bool` — now + 24 ч (константа `ReminderSnoozeMs`, не магия; snooze L257261);
выключатель не проверяется; карточки нет → false (404).
- `CheckDueAsync(ct) → IReadOnlyList<ProjectReminderDueDto>` — disabled → `store.ClearExpiredAsync` +
пустой список (L266–269: протухшие не храним, при включении старые не «выстрелят»); enabled →
`ListDueAsync(now)` (hold, ≤now, не-fired, ORDER BY at) + `MarkFiredAsync` (L277278) + возврат due
{id,title,stage}. SSE reminder_due публикует Api-слой (Tasks 11/12, Ruling 8) — сервис события не шлёт.
- Приватный `ReadRemindersEnabledAsync` — эталон ReadBoolAsync модулей (Kanban/Pipeline): отсутствие
строки/повреждённый JSON → дефолт `SettingsDefaults.RemindersEnabled` (true).
- `Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs` — тело `{at}`: `long? At` (camelCase; nullable —
прецедент TakeLeadRequest/ProjectCommentRequest); отсутствующий/JSON-null at = клиентский баг (pydantic —
422) → эндпоинт отвечает 400.
- `tests/Deal.Tests.Unit/ProjectReminderServiceTests.cs` — 12 тестов (ниже).
- `.superpowers/sdd/deal-stage5-projects/task-10-curl-acceptance.sh` (+ лог `task-10-curl-acceptance.log`).
### Изменён
- `Deal.Modules.Projects/Application/ProjectsModuleRegistrar.cs``AddScoped<ProjectReminderService>()`
(комментарий-каркас уже анонсировал Task 10).
- `Deal.Api/Endpoints/ProjectsEndpoints.cs` — группа 13 → **16 эндпоинтов**, 3 reminder-маршрута
(Ruling 9: `POST /{cardId}/reminder` и `DELETE /{cardId}/reminder` до `POST /{cardId}/reminder/snooze`):
- POST → карточка | 400 (напоминания выключены) | 404 «Карточка не найдена»; тело без at → 400
`ReminderAtMissingDetail` «Поле at (epoch-ms) обязательно» (недостижимо фронтом; новый не-прототипный
текст на месте FastAPI-422 — прецедент FormExpectedDetail Task 9/InvalidBodyDetail Task 8). Все —
после 401-гейта HasUser + RequestServices-резолва (паттерн группы).
- DELETE → `{ok:true}` | 404; POST /snooze → `{ok:true}` | 404. Класс-док группы обновлён (Tasks 8/9/10).
## Решения и замечания
- **Настройка — общий `remindersEnabled`**: отдельный ключ «напоминания об отложенных» НЕ заводится —
Ruling 3 L112113: уже готовый публичный ключ SettingsKeys.RemindersEnabled (дефолт true,
SettingsDefaults L117; PATCH /api/settings работает с этапа 2), фронт открывает HoldReminderDialog по
`state.remindersEnabled` (store.js L19761980) — тот же флаг. Зависимость на доработку Settings НЕ
требуется.
- **Валидации времени в будущем НЕТ** (отклонение от формулировки в ТЗ задачи): Ruling 3/прототип
set_reminder L236243 at не валидируют, а приёмка Tasks 11/13 требует set на hold-карточку с at в
ПРОШЛОМ (now−1 мин) для «выстреливания» ручным тиком — future-валидация сломала бы её. Прошлое at = при
ближайшем тике сработает (1:1 прототип).
- **Порядок 404/400 у set**: карточка раньше выключателя (404 раньше 400) — 1:1 с роутером
`_card_or_404` (L193) до вызова set_reminder (L194197); комбинация «карточки нет + выключено»
недостижима фронтом, зафиксирована тестом `Set_MissingCard_Returns404EvenWhenDisabled` (404, как
прототип).
- **Snooze через SetReminderAsync хранилища**: отдельного порт-метода snooze нет (порт — ровно задачи
312, YAGNI); SetReminderAsync сбрасывает fired (как snooze L259) и бампит UpdatedAt — отличие от
python snooze (updated_at не трогает) зафиксировано в XML-doc сервиса; фронт (store.js L21222131)
счётчик после snooze не перечитывает — влияния нет.
- Прошлые set/сработавшие → tick Tasks 11/12; due-«фired»-признак в DTO не выходит (держит строка БД) —
тесты проверяют через повторный ListDueAsync (пуст), как в FakeProjectStore.
## Проверка
1. `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений (TreatWarningsAsErrors).
2. `dotnet test Deal.sln`**614/614 PASS** (602 + 12 ProjectReminderServiceTests; таргетный фильтр
ProjectReminderServiceTests — 12/12 зелёные).
3. Curl-приёмка :5080 (`task-10-curl-acceptance.sh``task-10-curl-acceptance.log`) — **PASS=32 FAIL=0**:
очистка ProjectCards/settings → запуск (LocalFileStorage) → 401 без куки (set/delete/snooze) → login →
404 на pr_dead (set/delete/snooze), 400 set `{}` → локальная карточка → move hold → set {at:+1 мин} →
карточка с `reminder:{at}`, GET подтверждает → DELETE {ok:true}, reminder null → повторный set → snooze
→ reminder.at = now+24 ч (±2 с) → move ready: reminder сброшен → move hold → PATCH settings
remindersEnabled=false → set → 400 «Напоминания об отложенных выключены в настройках» → snooze/DELETE
200 (выключатель не проверяет) → PATCH restore true → logout → 401. После приёмки строки ProjectCards и
строка настройки очищены (psql 0/0), порт :5080 свободен.
4. Стиль: 1 тип = 1 файл; XML-doc на публичные контракты; именованные константы (ReminderSnoozeMs,
RemindersDisabledDetail); комментарии на русском; без регионов.
## Отчёт
`.superpowers/sdd/deal-stage5-projects/task-10-report.md`; ledger progress.md обновлён (Task 10 complete).
@@ -0,0 +1,261 @@
#!/usr/bin/env sh
# Task 11 curl-приёмка: POST /api/admin/tick → reminders [{id,title,stage}] + SSE reminder_due на :5080
# (план Task 11 L434-452, Ruling 3/8; dashboard_routes.py admin_tick L327-337; projects.py check_reminders
# L264-282; api-map §3.2 L103-112). Сценарий: очистка ProjectCards + settings.remindersEnabled -> запуск
# Deal.Api (Development, DEAL_DEMO=1) -> 401 без куки на /admin/tick -> login admin/admin -> локальная
# карточка + move hold + PATCH title -> SSE-подписка (curl -N в фон) -> POST reminder {at: now-1 мин}
# (прошлое допустимо, Ruling 3) -> POST /admin/tick: ответ reminders:[{id,title,stage:'hold'}] 1:1 с SSE
# reminder_due, psql ReminderFired=true (MarkFired) -> второй tick: reminders:[] (повторно не «выстреливает»)
# -> выключенные напоминания (remindersEnabled=false) + протухшая строка -> tick: reminders:[] и очистка
# ReminderAt (check_reminders L266-269) -> restore true -> logout -> 401. Очистка строк/настроек после приёмки.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' не
# конвертируются MSYS-рантаймом; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
TMPB=$(cygpath -m /tmp)/task11
JAR="$TMPB/jar.txt"
OUT="$TMPB/out.txt"
HDR="$TMPB/hdr.txt"
LOG="$TMPB/api.log"
SSE_LOG="$TMPB/sse.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
SSE_PID=""
PRJ=""
PRJ2=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
# Проверка по содержимому файла ($1), не $OUT.
check_file() {
desc=$1
file=$2
shift 2
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$file"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено в $file: $*"
echo "--- содержимое:"
cat "$file"
fi
}
# Первый id (pr_) из JSON-тела ответа.
extract_id() {
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
stop_app() {
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка Api и очистка созданных строк/настроек =="
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
kill "$SSE_PID" 2>/dev/null
fi
stop_app "$APP_PID"
if [ -n "$PRJ" ]; then
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
fi
if [ -n "$PRJ2" ]; then
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
fi
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
rm -rf "$TMPB"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$HDR" "$LOG" "$SSE_LOG"
mkdir -p "$TMPB"
echo "== 0. Очистка ProjectCards и настройки remindersEnabled дефолтного тенанта (повторяемость) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] ProjectCards пусты, remindersEnabled — дефолт (true)"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
grep -q 'LocalFileStorage' "$LOG"
if [ $? = 0 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
head -n 3 "$LOG"
fi
echo " health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. POST /admin/tick без сессии → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 3. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 4. Локальная карточка → move hold → заголовок для SSE/ответа =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
PRJ=$(extract_id)
echo " -> PRJ: $PRJ"
if [ -z "$PRJ" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
check "move hold 200" '[HTTP:200]' '"stage":"hold"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"title":"Bot hold"}' "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "PATCH title 200" '[HTTP:200]' '"title":"Bot hold"'
echo
echo "== 5. SSE-подписка на /api/events (фон) + reminder в прошлом (at = now-1 мин) =="
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>/dev/null &
SSE_PID=$!
sleep 2
AT_PAST=$(( $(date +%s) * 1000 - 60000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_PAST}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "POST reminder в прошлом 200 — карточка с напоминанием" '[HTTP:200]' '"reminder":{"at":'"$AT_PAST"'}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "GET карточки — hold + reminder в прошлом" '[HTTP:200]' '"stage":"hold"' '"reminder":{"at":'"$AT_PAST"'}'
echo
echo "== 6. POST /admin/tick → reminders:[{id,title,stage:'hold'}] (после MarkFired) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick 200 — форма {storage,reminders,pipeline,queue}" '[HTTP:200]' '"storage":{' '"reminders":[' '"pipeline":{' '"queue":'
check "reminders ответа = due {id,title,stage:'hold'}" '"reminders":[{"id":"'"$PRJ"'","title":"Bot hold","stage":"hold"}]'
echo "--- tick-ответ:"
cat "$OUT"
echo
echo
echo "== 7. SSE-подписка получила reminder_due {id,title,stage}; psql ReminderFired=true =="
sleep 1
kill "$SSE_PID" 2>/dev/null
SSE_PID=""
check_file "SSE: событие reminder_due пришло" "$SSE_LOG" 'event: reminder_due' '"id":"'"$PRJ"'"' '"title":"Bot hold"' '"stage":"hold"'
echo "--- sse.log:"
cat "$SSE_LOG"
echo
FIRED=$($PSQL_BASE -t -A -c "SELECT \"ReminderFired\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';")
if [ "$FIRED" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: ReminderFired=true (MarkFired после tick)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: ReminderFired=$FIRED (ожидался t)"
fi
echo
echo "== 8. Второй tick → reminders:[] (повторно не «выстреливает») =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "второй tick 200, reminders пуст" '[HTTP:200]' '"reminders":[]'
echo
echo "== 9. Выключенные напоминания: tick чистит протухшие, reminders:[] =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
check "вторая карточка создана" '[HTTP:200]' '"local":true'
PRJ2=$(extract_id)
echo " -> PRJ2: $PRJ2"
if [ -z "$PRJ2" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ2/move" > "$OUT"
check "move hold PRJ2 200" '[HTTP:200]' '"stage":"hold"'
AT_FUTURE=$(( $(date +%s) * 1000 + 120000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_FUTURE}" "$BASE_URL/api/projects/$PRJ2/reminder" > "$OUT"
check "POST reminder PRJ2 (будущее) 200" '[HTTP:200]' '"reminder":{"at":'"$AT_FUTURE"'}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":false}' "$BASE_URL/api/settings" > "$OUT"
check "PATCH remindersEnabled=false 200" '[HTTP:200]' '"remindersEnabled":false'
# Протухшая строка при выключенной настройке (имитация «осталась от включённого режима»).
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"ProjectCards\" SET \"ReminderAt\" = now() - interval '1 minute' WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick при выключенных → reminders:[]" '[HTTP:200]' '"reminders":[]'
CLEARED=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2' AND \"ReminderAt\" IS NULL AND \"ReminderFired\" = false;")
if [ "$CLEARED" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: протухшее напоминание PRJ2 очищено (ReminderAt NULL, ReminderFired false)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: протухшее напоминание PRJ2 не очищено (строк с ReminderAt NULL: $CLEARED)"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":true}' "$BASE_URL/api/settings" > "$OUT"
check "PATCH restore remindersEnabled=true 200" '[HTTP:200]' '"remindersEnabled":true'
echo
echo "== 10. Logout → tick → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
exit 1
fi
@@ -0,0 +1,71 @@
# Task 11 — POST /api/admin/tick: reminders [{id,title,stage}] + SSE reminder_due — отчёт
Статус: **DONE** (build 0/0; **617/617 PASS** — 614 этапов 110 + 3 новых AdminTickOrchestratorTests;
curl-приёмка :5080 — **23/23 PASS**). План:
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 11 (L434452), Ruling 3 (напоминания
«Отложено»: CheckDueAsync disabled→очистка протухших+[] / enabled→due-fired {id,title,stage}) и Ruling 8
(SSE `reminder_due` несёт {id,title,stage}, публикации только из Api, **дополнительный toast НЕ шлём**у
фронта модалка ReminderNotice); источники `backend/app/routers/dashboard_routes.py` admin_tick L327337,
`backend/app/services/projects.py` check_reminders L264282, api-map §3.2 L103112.
## Файлы
### Изменён — `Deal.Api/AdminTickOrchestrator.cs`
- Новая зависимость конструктора `ProjectReminderService reminders` (scoped, регистрация уже была через
`AddProjectsModule` — Task 10; в unit собирается на FakeProjectStore/FakeSettingsStore).
- Константа `ReminderDueEventType = "reminder_due"` (рядом с `NewLeadEventType`).
- В `TickAsync` между тостами и pump — шаг (4) «проверка напоминаний» (1:1 с admin_tick L332337: тик →
purge → тосты → **check_reminders** → pump → …): `reminders.CheckDueAsync(ct)` → по каждому due —
`broker.Publish(tenantId, "reminder_due", due)` (после MarkFired внутри сервиса — как прототип L277–281).
Ответ тика возвращает те же записи в `reminders` (после SSE). Сбой проверки НЕ роняет тик: `catch`
лог-предупреждение + `reminders:[]` (очередь/хранение продолжают); `OperationCanceledException`
пробрасывается (запрос прерван) — паттерн ветки pump. Класс-док: порядок теперь (1)–(7).
### Изменён — `Deal.Api/AdminTickResultDto.cs`
- `Reminders: IReadOnlyList<object>` → типизированный `IReadOnlyList<ProjectReminderDueDto>` (+using моделей
модуля Projects). XML-doc: reminders — «выстрелившие» напоминания {id,title,stage} = список SSE
reminder_due тика (этап 5); пусто — сработавших нет либо проверка недоступна.
### Изменён — `Deal.Api/Endpoints/StorageEndpoints.cs`
- Только документация (класс + AdminTickAsync): контракт теперь {storage, reminders, pipeline, queue},
шаг напоминаний в составе тика; сбой проверки напоминаний/pump не роняет тик.
### Изменён — тесты `tests/Deal.Tests.Unit/`
- `FakeProjectStore.cs``sealed` снят + `ListDueAsync``virtual` (прецедент FakePipelineStore.ListAsync:
тестовый подкласс со сбоем). Поведение не менялось.
- `AdminTickOrchestratorTests.cs` — +3 теста (ниже); Context дополнен `ProjectStore`/`Settings`;
`CreateContext` собирает реальный `ProjectReminderService` на общих фейках; подкласс
`ThrowingDueProjectStore` (ListDueAsync бросает) для сценария сбоя.
## Решения и замечания
- **Toast НЕ публикуется** (проверено по прототипу/плану): admin_tick L334 → check_reminders L280281 шлёт
только `reminder_due`; Ruling 8 L167 — «дополнительный toast НЕ шлём». В tick-ответе и SSE — только
{id,title,stage}.
- **Fake «ProjectReminderService»** (по Acceptance): сервис конкретный и sealed, порт-интерфейса у
оркестратора нет — по конвенции AdminTickOrchestratorTests тесты собирают реальный сервис модуля на
фейках; сбой проверки имитируется на слое хранилища (`ThrowingDueProjectStore`), как pump-сбой через
`ThrowingQueueReadPipelineStore`. Продакшн-код ради тестов не абстрагировался.
- **Порядок в тике**: reminders-проверка ДО pump и ДО new_lead (1:1 с admin_tick L334336); reminder_due
уходят до событий pump; reminders ответа — после SSE (api-map §3.2 L103112).
- **Титул в curl-приёмке — ASCII** («Bot hold»): русский текст в теле `curl -d` на Windows-native curl
уходит в ANSI-кодировке (сервер отвечал 500 «Cannot transcode invalid UTF-8») — это ограничение харнесса
приёмки, не API (SSE/reminders с русским заголовком покрыты unit-тестами: title «Отложенный бот»).
## Проверка
1. `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений (TreatWarningsAsErrors).
2. `dotnet test tests/Deal.Tests.Unit`**617/617 PASS** (614 + 3 новых AdminTickOrchestratorTests:
due-reminder → reminders ответа + SSE reminder_due + MarkFired; remindersEnabled=false → пусто и очистка
протухших; сбой ListDueAsync → reminders:[] без падения тика, pump продолжает).
3. Curl-приёмка :5080 (`task-11-curl-acceptance.sh``task-11-curl-acceptance.log`) — **PASS=23 FAIL=0**:
очистка ProjectCards/settings → запуск (LocalFileStorage) → 401 без куки на tick → login → локальная
карточка → move hold → SSE-подписка (curl -N) → reminder {at: now1 мин} (прошлое допустимо, Ruling 3) →
POST /admin/tick → ответ `reminders:[{id,title,stage:'hold'}]`, SSE-подписчику пришло `event: reminder_due`
`data:{"id":…,"title":"Bot hold","stage":"hold"}`, psql `ReminderFired=t` (MarkFired) → второй tick:
`reminders:[]` (повторно не «выстреливает») → remindersEnabled=false + протухшая строка (psql aging) →
tick: `reminders:[]`, psql ReminderAt=NULL (очистка L266269) → restore true → logout → 401. Строки/
настройки очищены после приёмки, порт :5080 свободен.
4. Стиль: 1 тип = 1 файл; XML-doc на публичные контракты; именованные константы (ReminderDueEventType);
комментарии на русском; без регионов.
## Отчёт
`.superpowers/sdd/deal-stage5-projects/task-11-report.md`; ledger progress.md обновлён (Task 11 complete).
@@ -0,0 +1,277 @@
#!/usr/bin/env sh
# Task 12 curl-приёмка: фоновый 30-с цикл StorageTickScheduler проверяет due-напоминания БЕЗ ручного tick
# на :5080 (план Task 12 L454-472; Rulings 3/8; _storage_loop main.py L47-53: тик -> тосты -> check_reminders;
# projects.py check_reminders L264-282). Сценарий: очистка ProjectCards + settings.remindersEnabled -> запуск
# Deal.Api (Development, DEAL_DEMO=1) -> login -> локальная карточка + move hold + PATCH title -> SSE-подписка
# (curl -N в фон) -> POST reminder {at: now-1 мин} -> НЕ вызывая POST /admin/tick дожидаемся фонового прохода
# (поллинг psql ReminderFired до ~45 с): SSE-подписчику приходит reminder_due {id,title,stage:'hold'}, psql
# ReminderFired=true (MarkFired внутри фоновой ветки) -> вторая карточка + reminder в будущем + aging в прошлое
# + remindersEnabled=false -> следующий фоновый проход: reminders НЕ «выстреливают» (событий reminder_due больше
# нет), протухшая строка PRJ2 очищена (ReminderAt NULL, L266-269) -> restore true -> logout -> 401. Очистка
# строк/настроек после приёмки. Окно прохода — 30 с: приёмка терпит ожидание (поллинг, не sleep на фикс. 30).
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' не
# конвертируются MSYS-рантаймом; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
TMPB=$(cygpath -m /tmp)/task12
JAR="$TMPB/jar.txt"
OUT="$TMPB/out.txt"
HDR="$TMPB/hdr.txt"
LOG="$TMPB/api.log"
SSE_LOG="$TMPB/sse.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
SSE_PID=""
PRJ=""
PRJ2=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
# Проверка по содержимому файла ($1), не $OUT.
check_file() {
desc=$1
file=$2
shift 2
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$file"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено в $file: $*"
echo "--- содержимое:"
cat "$file"
fi
}
# Первый id (pr_) из JSON-тела ответа.
extract_id() {
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
stop_app() {
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка Api и очистка созданных строк/настроек =="
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
kill "$SSE_PID" 2>/dev/null
fi
stop_app "$APP_PID"
if [ -n "$PRJ" ]; then
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
fi
if [ -n "$PRJ2" ]; then
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
fi
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
rm -rf "$TMPB"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$HDR" "$LOG" "$SSE_LOG"
mkdir -p "$TMPB"
echo "== 0. Очистка ProjectCards и настройки remindersEnabled дефолтного тенанта (повторяемость) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] ProjectCards пусты, remindersEnabled — дефолт (true)"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
grep -q 'LocalFileStorage' "$LOG"
if [ $? = 0 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
head -n 3 "$LOG"
fi
echo " health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 3. Локальная карточка → move hold → заголовок (SSE/psql будут проверять по нему) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
PRJ=$(extract_id)
echo " -> PRJ: $PRJ"
if [ -z "$PRJ" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ/move" > "$OUT"
check "move hold 200" '[HTTP:200]' '"stage":"hold"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"title":"Bot bg"}' "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "PATCH title 200" '[HTTP:200]' '"title":"Bot bg"'
echo
echo "== 4. SSE-подписка на /api/events (фон) + reminder в прошлом (at = now-1 мин) =="
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>/dev/null &
SSE_PID=$!
sleep 2
AT_PAST=$(( $(date +%s) * 1000 - 60000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_PAST}" "$BASE_URL/api/projects/$PRJ/reminder" > "$OUT"
check "POST reminder в прошлом 200 — карточка с напоминанием" '[HTTP:200]' '"reminder":{"at":'"$AT_PAST"'}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "GET карточки — hold + reminder в прошлом" '[HTTP:200]' '"stage":"hold"' '"reminder":{"at":'"$AT_PAST"'}'
echo
echo "== 5. Ждём фоновый 30-с проход БЕЗ ручного tick: поллинг psql ReminderFired (до ~45 с) =="
echo " (ручной POST /admin/tick в этой приёмке НЕ вызывается — сработать должен фоновый цикл)"
FIRED=""
i=0
while [ "$i" -lt 45 ]; do
FIRED=$($PSQL_BASE -t -A -c "SELECT \"ReminderFired\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" 2>/dev/null)
if [ "$FIRED" = "t" ]; then
break
fi
i=$((i + 2))
sleep 2
done
if [ "$FIRED" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] фоновый проход сработал за ~$((i + 2)) с: psql ReminderFired=true (MarkFired)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] за 45 с фоновый цикл не пометил напоминание fired (ReminderFired=$FIRED)"
echo "--- лог Api (хвост):"
tail -n 20 "$LOG"
fi
echo "--- события SSE на момент срабатывания:"
cat "$SSE_LOG"
echo
echo
echo "== 6. SSE-подписка получила reminder_due {id,title,stage:'hold'} =="
check_file "SSE: событие reminder_due пришло (фоновый цикл, без tick)" "$SSE_LOG" 'event: reminder_due' '"id":"'"$PRJ"'"' '"title":"Bot bg"' '"stage":"hold"'
echo
echo "== 7. Выключенные напоминания: следующий проход НЕ «выстреливает», протухшее очищается =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
check "вторая карточка создана" '[HTTP:200]' '"local":true'
PRJ2=$(extract_id)
echo " -> PRJ2: $PRJ2"
if [ -z "$PRJ2" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ2/move" > "$OUT"
check "move hold PRJ2 200" '[HTTP:200]' '"stage":"hold"'
AT_FUTURE=$(( $(date +%s) * 1000 + 120000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_FUTURE}" "$BASE_URL/api/projects/$PRJ2/reminder" > "$OUT"
check "POST reminder PRJ2 (будущее) 200" '[HTTP:200]' '"reminder":{"at":'"$AT_FUTURE"'}'
# Выключаем ДО aging: между отключением и следующим проходом комбинации enabled+due не будет — PRJ2 не
# «выстрелит» (гонки с идущим проходом нет: до aging ReminderAt в будущем, после aging цикл уже disabled).
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":false}' "$BASE_URL/api/settings" > "$OUT"
check "PATCH remindersEnabled=false 200" '[HTTP:200]' '"remindersEnabled":false'
# Протухшая строка при выключенной настройке (имитация «осталась от включённого режима», как T11-приёмка).
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"ProjectCards\" SET \"ReminderAt\" = now() - interval '1 minute', \"ReminderFired\" = false WHERE \"Id\" = '$PRJ2';" >/dev/null 2>&1
echo " Ждём следующий фоновый проход (поллинг psql ReminderAt PRJ2 до ~45 с)..."
CLEARED=""
i=0
while [ "$i" -lt 45 ]; do
CLEARED=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ2' AND \"ReminderAt\" IS NULL AND \"ReminderFired\" = false;" 2>/dev/null)
if [ "$CLEARED" = "1" ]; then
break
fi
i=$((i + 2))
sleep 2
done
if [ "$CLEARED" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] фоновый проход при выключенных очистил протухшее PRJ2 (ReminderAt NULL, ReminderFired false)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] протухшее напоминание PRJ2 не очищено за 45 с (строк ReminderAt NULL: $CLEARED)"
fi
EVENTS_TOTAL=$(grep -c 'event: reminder_due' "$SSE_LOG")
if [ "$EVENTS_TOTAL" = "1" ] && ! grep -q '"id":"'"$PRJ2"'"' "$SSE_LOG"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: событие reminder_due за приёмку ровно одно (PRJ2 не «выстрелил» при выключенных)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: reminder_due событий = $EVENTS_TOTAL (ожидалось 1), PRJ2 в логе: $(grep -c '"id":"'"$PRJ2"'"' "$SSE_LOG")"
echo "--- sse.log:"
cat "$SSE_LOG"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" -d '{"remindersEnabled":true}' "$BASE_URL/api/settings" > "$OUT"
check "PATCH restore remindersEnabled=true 200" '[HTTP:200]' '"remindersEnabled":true'
echo
echo "== 8. Logout → tick → 401 (ручной тик не использовался в сценарии выше) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "tick после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
exit 1
fi
@@ -0,0 +1,60 @@
# Task 12 — Фоновая проверка напоминаний в StorageTickScheduler (30 с) — отчёт
Статус: **complete** (build 0/0; **620/620 PASS** — 617 этапов 111 + 3 новых StorageTickSchedulerTests;
curl-приёмка :5080 — **19/19 PASS**). План:
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 12 (L454472), Rulings 3/8; источники
`backend/app/main.py` _storage_loop L4353 (порядок: тик → тосты → check_reminders), `backend/app/services/projects.py`
check_reminders L264282, AdminTickOrchestrator (ручной аналог, Task 11).
## Файлы
### Изменён — `Deal.Api/Hosting/StorageTickScheduler.cs`
- Новая singleton-зависимость конструктора `SseBroker broker` (эталон StorageToastPublisher L3644: guard
`ArgumentNullException.ThrowIfNull` + private readonly) и константа `ReminderDueEventType = "reminder_due"`.
- В `TickTenantAsync` после Kanban-тика/purge/тостов — шаг проверки напоминаний (1:1 с _storage_loop main.py
L4753): `ProjectReminderService` резолвится из tenant-scope ПОСЛЕ `SetTenant` (как остальные адаптеры) →
`CheckDueAsync(ct)` (помечает due-строки hold fired, возвращает {id,title,stage}) → SSE `reminder_due` по
каждой записи в канал тенанта (`broker.Publish(tenant.Id, …)`, Ruling 8: toast НЕ шлём, без подписчиков —
no-op). Сбой ветки НЕ роняет тик тенанта/проход: `OperationCanceledException` пробрасывается (остановка
хоста), прочие — `LogWarning` «проверка напоминаний тенанта не удалась» и пустой список (паттерн ветки
AdminTickOrchestrator). Класс-док и док `TickTenantAsync` обновлены (задачи 11–12, порядок тик → тосты → напоминания).
### Изменён — тесты `tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs`
- DI-провайдер дополнен: `IProjectStore` по тенанту (FakeProjectStore, паттерн IKanjStore/ISettingsStore) +
scoped `ProjectReminderService` (реальный сервис на фейках, как AdminTickOrchestratorTests); `CreateScheduler`
передаёт SseBroker; класс-док обновлён.
- +3 теста: (1) due-reminder hold в прошлом → ровно одно SSE `reminder_due` {id,title,stage} в канал тенанта A
(у B событий нет), MarkFired (повторный ListDueAsync пуст), контекст сброшен; (2) remindersEnabled=false →
событий нет, протухшее очищено (Reminder null, L266269); (3) сбой ветки тенанта A (подкласс
`ThrowingDueProjectStore`, ListDueAsync бросает — прецедент T11) → тик A не падает, тенант B обрабатывается
(автоархив + тост), проход жив. Хелперы: `HoldCard`/`NowMs`/`ReadEvents`.
## Решения и замечания
- **Оркестратор фоном НЕ переиспользуется** (в его составе pump/new_lead/queue — это отдельный 2-с цикл
PipelineWorkerScheduler): per-tenant шаг добавлен в StorageTickScheduler, как и предписывает Task 12. Логика
проверки НЕ дублируется — переиспользуется модульный `ProjectReminderService.CheckDueAsync` (фоновый тик и
ручной тик T11 зовут один и тот же сервис); переиспользованы существующие StorageTickService/
PipelineProcessingService/StorageToastPublisher. Публикация SSE — тот же 2-строчный идиоматический вызов
брокера, что в AdminTickOrchestrator (прецедент new_lead/toast: публикации из Api-классов без общего хелпера
для событий с одной точкой цикла).
- **Порядок**: тик → purge → тосты → напоминания (1:1 с _storage_loop main.py L4753: тик → тосты →
check_reminders); purge остаётся сразу после тика, как в Task 11.
- **Заголовок curl-приёмки — ASCII** («Bot bg»): ограничение Windows-native curl (ANSI-тело → 500), как в
T11-приёмке; русский title покрыт unit-тестом («Отложенный бот»).
## Проверка
1. `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений (TreatWarningsAsErrors).
2. `dotnet test tests/Deal.Tests.Unit`**620/620 PASS** (617 + 3 новых).
3. Curl-приёмка :5080 (`task-12-curl-acceptance.sh``task-12-curl-acceptance.log`) — **PASS=19 FAIL=0**:
очистка ProjectCards/settings → запуск (LocalFileStorage) → login → локальная карточка → move hold → PATCH
title → SSE-подписка → reminder {at: now1 мин} → **БЕЗ ручного POST /admin/tick** фоновый 30-с проход за
~24 с: psql ReminderFired=t (MarkFired), SSE-подписчику пришло `event: reminder_due`
`data:{"id":…,"title":"Bot bg","stage":"hold"}` → PRJ2 (reminder будущее → aging в прошлое + выключенные) →
следующий проход: события reminder_due БОЛЬШЕ нет (ровно 1 за приёмку), psql ReminderAt=NULL/Fired=false
(очистка L266269) → restore true → logout → tick 401. Строки/настройки очищены после приёмки, порт :5080
свободен, процесс остановлен.
4. Стиль: 1 тип = 1 файл; XML-doc на публичные контракты; именованные константы (ReminderDueEventType);
комментарии на русском; без регионов.
## Отчёт
`.superpowers/sdd/deal-stage5-projects/task-12-report.md`; ledger progress.md обновлён (Task 12 complete).
@@ -0,0 +1,639 @@
#!/usr/bin/env sh
# Task 13 — ФИНАЛ этапа 5: сквозная curl-приёмка «Выбранных» на :5080 (план Task 13 L474-494,
# Rulings 3/5/6/7/9/11; projects_routes.py целиком; api-map §3.5 L153-174, §2 SSE L33-43, §4.3 L280-304).
# Один сквозной сценарий: очистка канбана/проектных таблиц/очереди/отсева + вложений -> запуск Deal.Api
# (Development, DEAL_DEMO=1, LocalFileStorage) -> 401 без куки -> login -> GET /projects {items:[]} ->
# demo-лид (simulate) -> take {leadId}: лид col=taken + is_new=false (psql), исчез из /leads и /api/search,
# проектная local=false/planned/leadId/история created/комментарий «Взял в работу из лида.», поля Title/
# Summary скопированы (psql), повторный take -> та же карточка -> PATCH карточки (title/stack/budget/contact/
# tzText) -> локальная карточка POST /projects (поля) -> PATCH (в т.ч. budget:null) -> move reply->work->hold
# (история: 4 записи) -> комментарий -> ссылки add/remove -> hold-стадия + POST reminder {at: now-1 мин} ->
# БЕЗ ручного tick фоновый 30-с проход StorageTickScheduler: SSE reminder_due {id,title:'T13 Hold Card',
# stage:'hold'} + psql ReminderFired=t -> move hold->ready (reminder null) -> файлы: upload 2 (tz.pdf
# document/Документ, photo.png image/Изображение) -> мета в карточке (files) + объекты на диске ->
# download (байты совпадают, attachment, Content-Length) -> DELETE photo (файл ушёл с диска и из меты) ->
# GET /projects (UpdatedAt DESC: локальная первая) -> move локальной в rejected -> clear-rejected
# {ok,cleared:1} (повторный -> cleared:0) -> psql: UNIQUE LeadId (вставка дубля -> ошибка индекса) ->
# GET /projects/reminders и DELETE /{id} — 404 маршрута нет (Ruling 9) -> logout -> 401. После приёмки —
# остановка Api, очистка созданных строк/вложений (dev-БД чиста для этапа 6).
# Примечания: title карточки для SSE/reminder — ASCII (ограничение Windows-native curl, как T11/T12);
# тела запросов — ASCII; русские строки проверяются в ОТВЕТАХ сервера (UTF-8).
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe: аргументы с '=' и пути -F/@
# не конвертируются MSYS-рантаймом корректно; -c/-b/-o/-D должны видеть один и тот же путь bash и curl).
TMPB=$(cygpath -m /tmp)/task13
SRC="$TMPB/files"
JAR="$TMPB/jar.txt"
OUT="$TMPB/out.txt"
HDR="$TMPB/hdr.txt"
HDRN="$TMPB/hdrn.txt"
DL="$TMPB/dl.bin"
LOG="$TMPB/api.log"
SSE_LOG="$TMPB/sse.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
ATTACH="$API_DIR/data/attachments"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
SSE_PID=""
PRJ_A=""
PRJ_L=""
LEAD_ID=""
LEAD_TOKEN=""
LINK_A=""
FID_PDF=""
FID_PNG=""
KEY_PDF=""
KEY_PNG=""
SZ_PDF=""
SZ_PNG=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
# Проверка по содержимому файла ($1), не $OUT.
check_file() {
desc=$1
file=$2
shift 2
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$file"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено в $file: $*"
echo "--- содержимое:"
cat "$file"
fi
}
# Проверка по заголовкам ответа (нормализованы в $HDRN: lowercase, без \r).
header_check() {
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$HDRN"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено в заголовках: $*"
echo "--- заголовки:"
cat "$HDRN"
fi
}
# Первый id (pr_/l_) из JSON-тела ответа (тело — первая строка $OUT, вторая — служебный [HTTP:...]).
extract_id() {
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\|l_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
# Первый id карточки списка GET /api/projects (UpdatedAt DESC — первая строка items).
extract_first_list_id() {
sed -n '1{s/.*"items":\[{"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
# updatedAt (epoch-ms) из тела ответа.
extract_updated_at() {
sed -n '1{s/.*"updatedAt":\([0-9][0-9]*\).*/\1/p}' "$OUT"
}
# Токен поиска из поля contact лида («@crm_head» → crm_head): для проверки «лид исчез из /api/search».
extract_contact_token() {
sed -n '1{s/.*"contact":"@\([A-Za-z0-9_]*\)".*/\1/p}' "$OUT"
}
# n-й файловый id (pf_) из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
file_id_at() {
grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$1" | sed -n "${2}s/.*\"id\":\"\(pf_[0-9a-f][0-9a-f]*\)\"/\1/p"
}
# n-й objectKey из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
object_key_at() {
grep -o '"objectKey":"[^"]*"' "$1" | sed -n "${2}s/.*\"objectKey\":\"\([^\"]*\)\"/\1/p"
}
stop_app() {
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка Api и очистка созданных строк/настроек/вложений =="
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
kill "$SSE_PID" 2>/dev/null
fi
stop_app "$APP_PID"
if [ -n "$LEAD_ID" ]; then
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\" WHERE \"CardId\" = '$LEAD_ID';" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';" >/dev/null 2>&1
fi
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" IN ('$PRJ_A','$PRJ_L');" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null 2>&1
rm -rf "$ATTACH" 2>/dev/null
rm -rf "$TMPB"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$HDR" "$HDRN" "$DL" "$LOG" "$SSE_LOG"
mkdir -p "$TMPB" "$SRC"
printf '%s' '%PDF-1.4 Task13 tz document bytes 1234567890' > "$SRC/tz.pdf"
printf '%s' 'Task13 photo bytes png 0987654321 xyz' > "$SRC/photo.png"
SZ_PDF=$(wc -c < "$SRC/tz.pdf")
SZ_PNG=$(wc -c < "$SRC/photo.png")
echo "== 0. Очистка канбана/проектных таблиц/очереди/отсева и вложений дефолтного тенанта (повторяемость) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"DedupEntries\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"QueueItems\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"RejectedItems\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"settings\" WHERE \"Key\" = 'remindersEnabled';" >/dev/null
rm -rf "$ATTACH" 2>/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\") + (SELECT count(*) FROM \"$SCHEMA\".\"QueueItems\") + (SELECT count(*) FROM \"$SCHEMA\".\"RejectedItems\");")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] Cards/ProjectCards/QueueItems/RejectedItems пусты, вложения удалены, remindersEnabled — дефолт (true)"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
grep -q 'LocalFileStorage' "$LOG"
if [ $? = 0 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
head -n 3 "$LOG"
fi
echo " health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. 401 без сессии на /api/projects* =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST -H "Content-Type: application/json" -d '{"leadId":"l_x"}' "$BASE_URL/api/projects/take" > "$OUT"
check "POST /take без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 3. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 4. GET /api/projects пуст (заглушка снята: реальный список); 404 маршрутов нет (Ruling 9) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects → 200 {items:[]}" '[HTTP:200]' '{"items":[]}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/reminders" > "$OUT"
check "GET /api/projects/reminders → 404 (список напоминаний НЕ реализован, Ruling 9)" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/pr_dead00000000" > "$OUT"
check "DELETE /api/projects/{id} → 405 (маршрут DELETE не реализован, Ruling 9; .NET: 405 по пути GET/PATCH)" '[HTTP:405]'
echo
echo "== 5. Demo-лид (simulate) → виден в inbox и в /api/search =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead 200 (demo)" '[HTTP:200]' '"id":"l_' '"col":"inbox"'
LEAD_ID=$(extract_id)
LEAD_TOKEN=$(extract_contact_token)
echo " -> LEAD_ID: $LEAD_ID, LEAD_TOKEN: $LEAD_TOKEN"
if [ -z "$LEAD_ID" ] || [ -z "$LEAD_TOKEN" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
check "лид виден в inbox до take" '[HTTP:200]' "\"id\":\"$LEAD_ID\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/search?q=$LEAD_TOKEN" > "$OUT"
check "поиск находит лида до take (контроль механизма)" '[HTTP:200]' "\"id\":\"$LEAD_ID\""
echo
echo "== 6. POST /api/projects/take {leadId}: лид уходит в taken, проектная создана =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
check "take лида 200: карточка из лида" '[HTTP:200]' '"local":false' "\"leadId\":\"$LEAD_ID\"" '"stage":"planned"'
check "комментарий «Взял в работу из лида.»" '"text":"Взял в работу из лида."'
check "история created" '"type":"created"'
PRJ_A=$(extract_id)
echo " -> PRJ_A: $PRJ_A"
if [ -z "$PRJ_A" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
if grep -qF -- "\"id\":\"$LEAD_ID\"" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] лид остался виден в inbox после take"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] GET /leads?col=inbox больше не видит лида"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/search?q=$LEAD_TOKEN" > "$OUT"
if grep -qF -- "\"id\":\"$LEAD_ID\"" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] лид остался в /api/search после take (col=taken исключается из поиска)"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] /api/search больше не видит лида (taken)"
fi
LEAD_COL=$($PSQL_BASE -t -A -c "SELECT \"Col\" || '|' || \"IsNew\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';")
if [ "$LEAD_COL" = "taken|false" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: лид col=taken, is_new=false"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] лид = $LEAD_COL (ожидалось taken|false)"
fi
PRJ_ROW=$($PSQL_BASE -t -A -c "SELECT \"Stage\" || '|' || \"Local\" || '|' || \"LeadId\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_A';")
if [ "$PRJ_ROW" = "planned|false|$LEAD_ID" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: проектная карточка planned/local=false/leadId"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: строка ProjectCards = $PRJ_ROW"
fi
COPY_OK=$($PSQL_BASE -t -A -c "SELECT (c.\"Title\" = p.\"Title\") AND (c.\"Summary\" = p.\"Summary\") FROM \"$SCHEMA\".\"Cards\" c JOIN \"$SCHEMA\".\"ProjectCards\" p ON p.\"LeadId\" = c.\"Id\" WHERE c.\"Id\" = '$LEAD_ID';")
if [ "$COPY_OK" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: Title/Summary скопированы из лида в проектную карточку"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: копирование полей лида нарушено (Title/Summary не равны)"
fi
echo
echo "== 7. Повторный take того же лида — идемпотентность (та же карточка) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
check "повторный take → та же карточка" '[HTTP:200]' "\"id\":\"$PRJ_A\"" "\"leadId\":\"$LEAD_ID\""
echo
echo "== 8. PATCH карточки из лида (title/stack/budget/contact/tzText) → поля обновлены, updatedAt вырос =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_A" > "$OUT"
T_BEFORE=$(extract_updated_at)
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d '{"title":"T13 Patched Card","stack":["Go","Redis"],"budget":{"from":2000,"to":4000,"cur":"EUR"},"contact":"@t13patch","tzText":"patched tz"}' \
"$BASE_URL/api/projects/$PRJ_A" > "$OUT"
check "PATCH 200: изменения на месте" '[HTTP:200]' '"title":"T13 Patched Card"' '"stack":["Go","Redis"]' '"budget":{"from":2000,"to":4000,"cur":"EUR"}' '"contact":"@t13patch"' '"tzText":"patched tz"'
T_AFTER=$(extract_updated_at)
if [ -n "$T_BEFORE" ] && [ "$T_AFTER" -gt "$T_BEFORE" ] 2>/dev/null; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] updatedAt вырос ($T_BEFORE$T_AFTER)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] updatedAt не вырос: $T_BEFORE$T_AFTER"
fi
echo
echo "== 9. Локальная карточка POST /api/projects (поля) → local=true, createdLocal =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"title":"T13 Hold Card","summary":"local card summary","stack":["CSharp","SqlServer"],"budget":{"from":3000,"to":5000,"cur":"EUR"},"contact":"@t13local","tzText":"asap","stage":"planned"}' \
"$BASE_URL/api/projects" > "$OUT"
check "локальная карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"' '"title":"T13 Hold Card"' '"contact":"@t13local"' '"stack":["CSharp","SqlServer"]' '"budget":{"from":3000,"to":5000,"cur":"EUR"}'
check "история createdLocal" '"type":"createdLocal"'
PRJ_L=$(extract_id)
echo " -> PRJ_L: $PRJ_L"
if [ -z "$PRJ_L" ]; then exit 1; fi
echo
echo "== 10. PATCH локальной карточки: budget → set, затем budget:null (presence-aware очистка) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d '{"budget":{"from":1000,"to":2000,"cur":"USD"}}' "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
check "PATCH budget 200" '[HTTP:200]' '"budget":{"from":1000,"to":2000,"cur":"USD"}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d '{"budget":null}' "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
check "PATCH budget:null → бюджет очищен" '[HTTP:200]' '"budget":null'
echo
echo "== 11. Move по стадиям reply → work → hold: история растёт (4 записи: createdLocal + 3 stage) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"reply"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
check "move reply 200" '[HTTP:200]' '"stage":"reply"' '"reminder":null'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"work"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
check "move work 200" '[HTTP:200]' '"stage":"work"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"hold"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
check "move hold 200" '[HTTP:200]' '"stage":"hold"' '"reminder":null'
H_TOTAL=$(grep -o '"id":"h_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l | tr -d ' ')
if [ "$H_TOTAL" = "4" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] история на hold: 4 записи (1 createdLocal + 3 move со stage-ключами) — движение по стадиям дописывается"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] история на hold: h_=$H_TOTAL (ожидалось 4)"
fi
check "в истории есть move-записи reply и work (append при move)" '"stage":"reply"' '"stage":"work"'
echo
echo "== 12. Комментарии и ссылки на локальной карточке =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"text":" "}' "$BASE_URL/api/projects/$PRJ_L/comments" > "$OUT"
check "пустой комментарий → 400 «Пустой комментарий»" '[HTTP:400]' 'Пустой комментарий'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"text":"t13 comment one"}' "$BASE_URL/api/projects/$PRJ_L/comments" > "$OUT"
check "комментарий → {comments:[...]}" '[HTTP:200]' '"comments":[{"id":"cm_' '"text":"t13 comment one"' '"by":"Вы"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"url":"example.com"}' "$BASE_URL/api/projects/$PRJ_L/links" > "$OUT"
check "ссылка без схемы → https://, name = url" '[HTTP:200]' '"links":[{"id":"pl_' '"name":"https://example.com"' '"url":"https://example.com"'
LINK_A=$(sed -n '1{s/.*"links":\[{"id":"\(pl_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT")
echo " -> LINK_A: $LINK_A"
if [ -z "$LINK_A" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"name":"Site","url":"http://x.ru"}' "$BASE_URL/api/projects/$PRJ_L/links" > "$OUT"
check "вторая ссылка: http:// сохранён" '[HTTP:200]' '"name":"Site"' '"url":"http://x.ru"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ_L/links/$LINK_A" > "$OUT"
check "DELETE ссылки 200 — карточка без удалённой" '[HTTP:200]' "\"id\":\"$PRJ_L\""
if grep -qF -- "$LINK_A" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] удалённая ссылка осталась в карточке"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] удалённой ссылки в ответе нет"
fi
echo
echo "== 13. SSE-подписка (фон) + POST reminder {at: now-1 мин} на hold-карточке =="
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_LOG" 2>/dev/null &
SSE_PID=$!
sleep 2
AT_PAST=$(( $(date +%s) * 1000 - 60000 ))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d "{\"at\":$AT_PAST}" "$BASE_URL/api/projects/$PRJ_L/reminder" > "$OUT"
check "POST reminder в прошлом 200 — карточка с напоминанием" '[HTTP:200]' '"reminder":{"at":'"$AT_PAST"'}' '"stage":"hold"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
check "GET карточки — hold + reminder в прошлом" '[HTTP:200]' '"stage":"hold"' '"reminder":{"at":'"$AT_PAST"'}'
echo
echo "== 14. БЕЗ ручного tick ждём фоновый 30-с проход: psql ReminderFired (поллинг до ~60 с) =="
echo " (ручной POST /admin/tick в сценарии НЕ вызывается — напоминание должен снять фоновый StorageTickScheduler)"
FIRED=""
i=0
while [ "$i" -lt 30 ]; do
FIRED=$($PSQL_BASE -t -A -c "SELECT \"ReminderFired\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_L';" 2>/dev/null)
if [ "$FIRED" = "t" ]; then
break
fi
i=$((i + 1))
sleep 2
done
if [ "$FIRED" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] фоновый проход сработал за ~$((i * 2 + 2)) с: psql ReminderFired=true (MarkFired, без ручного tick)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] за ~60 с фоновый цикл не пометил напоминание fired (ReminderFired=$FIRED)"
echo "--- лог Api (хвост):"
tail -n 20 "$LOG"
fi
echo "--- события SSE на момент срабатывания:"
cat "$SSE_LOG"
echo
check_file "SSE: событие reminder_due {id,title:'T13 Hold Card',stage:'hold'} пришло фоновым циклом" "$SSE_LOG" 'event: reminder_due' '"id":"'"$PRJ_L"'"' '"title":"T13 Hold Card"' '"stage":"hold"'
EVENTS_TOTAL=$(grep -c 'event: reminder_due' "$SSE_LOG")
if [ "$EVENTS_TOTAL" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] событий reminder_due за приёмку ровно одно"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] reminder_due событий = $EVENTS_TOTAL (ожидалось 1)"
fi
kill "$SSE_PID" 2>/dev/null
SSE_PID=""
echo
echo "== 15. Move hold → ready: напоминание снято (reminder null), история 5 записей =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"ready"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
check "move ready 200" '[HTTP:200]' '"stage":"ready"' '"reminder":null'
REMN=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_L' AND \"ReminderAt\" IS NULL AND \"ReminderFired\" = false;")
if [ "$REMN" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: после move с hold напоминание очищено (ReminderAt NULL, ReminderFired false)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: напоминание не очищено move (строк ReminderAt NULL: $REMN)"
fi
H_TOTAL=$(grep -o '"id":"h_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l | tr -d ' ')
if [ "$H_TOTAL" = "5" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] история на ready: 5 записей (append при каждом move)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] история на ready: h_=$H_TOTAL (ожидалось 5)"
fi
echo
echo "== 16. Файлы: upload 2 (tz.pdf document, photo.png image) → мета в карточке + объекты на диске =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
-F "files=@$SRC/tz.pdf;type=application/pdf;filename=tz.pdf" \
-F "files=@$SRC/photo.png;type=image/png;filename=photo.png" \
"$BASE_URL/api/projects/$PRJ_L/files" > "$OUT"
check "upload 200 {items:[2]}" '[HTTP:200]' '"items":[' '"id":"pf_'
check "tz.pdf → document/Документ" '"name":"tz.pdf"' '"kind":"document"' '"label":"Документ"'
check "photo.png → image/Изображение" '"name":"photo.png"' '"kind":"image"' '"label":"Изображение"'
check "size записей = размеры файлов" "\"size\":$SZ_PDF" "\"size\":$SZ_PNG"
FID_PDF=$(file_id_at "$OUT" 1)
FID_PNG=$(file_id_at "$OUT" 2)
KEY_PDF=$(object_key_at "$OUT" 1)
KEY_PNG=$(object_key_at "$OUT" 2)
echo " -> FID_PDF: $FID_PDF, FID_PNG: $FID_PNG"
echo " -> KEY_PDF: $KEY_PDF"
if [ -z "$FID_PDF" ] || [ -z "$FID_PNG" ] || [ -z "$KEY_PDF" ] || [ -z "$KEY_PNG" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
check "карточка с files: оба файла в массиве" '[HTTP:200]' '"files":[' '"name":"tz.pdf"' '"name":"photo.png"'
FILES_COUNT=$(grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l | tr -d ' ')
if [ "$FILES_COUNT" = "2" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] files содержит 2 записи (счётчики карточки)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] files содержит записей: $FILES_COUNT"
fi
if [ -f "$ATTACH/$KEY_PDF" ] && [ -f "$ATTACH/$KEY_PNG" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql/диск: объекты лежат по objectKey в data/attachments"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] объект(ы) не найдены на диске: $ATTACH/$KEY_PDF, $ATTACH/$KEY_PNG"
fi
FJ_OK=$($PSQL_BASE -t -A -c "SELECT position('$KEY_PDF' in \"FilesJson\") > 0 FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_L';")
if [ "$FJ_OK" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: FilesJson карточки содержит objectKey (мета ↔ объект)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: objectKey не найден в FilesJson ($FJ_OK)"
fi
echo
echo "== 17. Download tz.pdf: 200, attachment, octet-stream, Content-Length, байты совпадают =="
curl -s -D "$HDR" -o "$DL" -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L/files/$FID_PDF/download" > "$OUT"
check "download 200" '[HTTP:200]'
tr -d '\r' < "$HDR" | tr '[:upper:]' '[:lower:]' > "$HDRN"
header_check "Content-Disposition attachment + имя" 'content-disposition:' 'attachment' 'tz.pdf'
header_check "Content-Type octet-stream (local-режим)" 'content-type: application/octet-stream'
header_check "Content-Length = размер файла" "content-length: $SZ_PDF"
if cmp -s "$SRC/tz.pdf" "$DL"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] байты download совпадают с загруженным tz.pdf"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] байты download НЕ совпадают с tz.pdf"
fi
echo
echo "== 18. DELETE photo.png: {ok:true}; мета и диск без файла; download удалённого → 404 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ_L/files/$FID_PNG" > "$OUT"
check "DELETE файла → 200 {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L" > "$OUT"
check "карточка без photo.png, tz.pdf жив" '"name":"tz.pdf"'
if grep -qF -- '"name":"photo.png"' "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] photo.png остался в files карточки"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] photo.png удалён из files карточки"
fi
if [ -f "$ATTACH/$KEY_PNG" ]; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] объект photo.png остался на диске"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] объект photo.png удалён из data/attachments"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_L/files/$FID_PNG/download" > "$OUT"
check "download удалённого → 404 «Карточка не найдена»" '[HTTP:404]' 'Карточка не найдена'
echo
echo "== 19. GET /api/projects — список из 2 карточек, первая = локальная (UpdatedAt DESC) ==";
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "обе карточки в списке" '[HTTP:200]' "\"id\":\"$PRJ_A\"" "\"id\":\"$PRJ_L\""
FIRST=$(extract_first_list_id)
if [ "$FIRST" = "$PRJ_L" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] сортировка UpdatedAt DESC: первой идёт локальная (последнее изменение)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] первая карточка списка = $FIRST (ожидалась $PRJ_L)"
fi
echo
echo "== 20. Локальная в rejected → clear-rejected {ok,cleared:1}; карточка из лида цела =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"stage":"rejected"}' "$BASE_URL/api/projects/$PRJ_L/move" > "$OUT"
check "move PRJ_L в rejected 200" '[HTTP:200]' '"stage":"rejected"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
check "clear-rejected → {ok:true, cleared:1}" '[HTTP:200]' '{"ok":true,"cleared":1}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
check "повторный clear-rejected → cleared:0" '[HTTP:200]' '{"ok":true,"cleared":0}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects: карточка из лида жива, очищенной нет" '[HTTP:200]' "\"id\":\"$PRJ_A\""
if grep -qF -- "\"id\":\"$PRJ_L\"" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] очищенная rejected-карточка осталась в списке"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] rejected-карточка удалена из списка"
fi
PRJ_COUNT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
if [ "$PRJ_COUNT" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: в ProjectCards осталась только карточка из лида (строки всех сценариев отработаны)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: строк ProjectCards после clear-rejected = $PRJ_COUNT (ожидалось 1)"
fi
echo
echo "== 21. psql: UNIQUE-индекс LeadId — вставка второго проекта с тем же лидом → ошибка =="
DUP_ERR=$($PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"ProjectCards\" (\"Id\",\"Stage\",\"Local\",\"LeadId\",\"Title\",\"Summary\",\"StackJson\",\"BudgetFrom\",\"BudgetTo\",\"BudgetCur\",\"Contact\",\"CommentsJson\",\"LinksJson\",\"FilesJson\",\"HistoryJson\",\"TzText\",\"ReminderAt\",\"ReminderFired\",\"CreatedAt\",\"UpdatedAt\") SELECT 'pr_unique_dup000',\"Stage\",\"Local\",\"LeadId\",\"Title\",\"Summary\",\"StackJson\",\"BudgetFrom\",\"BudgetTo\",\"BudgetCur\",\"Contact\",\"CommentsJson\",\"LinksJson\",\"FilesJson\",\"HistoryJson\",\"TzText\",\"ReminderAt\",\"ReminderFired\",\"CreatedAt\",\"UpdatedAt\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_A';" 2>&1)
echo "$DUP_ERR" | grep -q 'IX_ProjectCards_LeadId'
if [ $? = 0 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: duplicate key по IX_ProjectCards_LeadId (partial UNIQUE LeadId работает)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: дубль LeadId не отклонён уникальным индексом:"
echo "$DUP_ERR"
fi
echo
echo "== 22. Logout → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Проверка лога Api: исключений нет =="
if grep -qE 'Exception|\[ERR\]|Unhandled' "$LOG"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе Api есть исключения:"
grep -E 'Exception|\[ERR\]|Unhandled' "$LOG" | head -n 5
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] лог Api чист (без исключений)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть упавшие проверки — хвост лога Api:"
tail -n 30 "$LOG"
exit 1
fi
echo " [PASS] этап 5 Projects: сквозная приёмка пройдена"
exit 0
@@ -0,0 +1,89 @@
# Task 13 — «Финал этапа — интеграция и сквозная приёмка» — отчёт
Статус: **complete (review pending)**. Сборка 0 warnings / 0 errors (`dotnet build Deal.sln`,
`sh scripts/build.sh`); unit-тесты **620/620 PASS** (`dotnet test tests/Deal.Tests.Unit`,
`sh scripts/test.sh`); сквозная curl-приёмка на :5080 (DEAL_DEMO=1, admin/admin, LocalFileStorage) —
**75/75 PASS** (скрипт `task-13-curl-acceptance.sh`, лог `task-13-curl-acceptance.log`, exit 0). План:
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 13 (L474494) + Self-Review; Rulings
3/5/6/7/9/11. **Код и конфиги не менялись** — только доки/ledger и артефакты приёмки.
## Артефакты приёмки
- `task-13-curl-acceptance.sh` — один сквозной сценарий (все шаги ниже, PASS/FAIL каждого шага);
- `task-13-curl-acceptance.log` — прогон: `== Итог: PASS=75 FAIL=0 ==` (exit 0).
## Сценарий (что реально проверено на :5080 одним прогоном)
1. Очистка канбана/проектных таблиц/очереди/отсева + вложений → старт Deal.Api (DEAL_DEMO=1,
стартовый лог `LocalFileStorage`) → 401 без куки (GET /projects, POST /take) → login admin/admin.
2. `GET /api/projects``{items:[]}` (boot-заглушка снята — реальный список); `GET
/api/projects/reminders` → 404 «Карточка не найдена»; `DELETE /api/projects/{id}` → 405
(маршрута DELETE нет — Ruling 9; детали ниже).
3. **demo-лид (simulate)** → inbox и `/api/search?q=<contact-токен>` находят лида → **take {leadId}**:
проектная карточка `local=false/planned/leadId`, история `created`, комментарий «Взял в работу из
лида.»; psql: лид `Col='taken'` + `IsNew=false` в `Cards`, строка `ProjectCards` planned|false|leadId,
Title/Summary скопированы из лида (join psql); лид исчез из `/api/leads?col=inbox` и `/api/search`;
**повторный take → та же карточка** (идемпотентность).
4. PATCH карточки из лида (title/stack/budget/contact/tzText) → поля обновлены, `updatedAt` вырос.
5. **Локальное создание** POST /projects (title/summary/stack/budget/contact/tzText/stage) →
`local=true`, `createdLocal`; PATCH `budget` → set, затем **`budget:null`** (presence-aware очистка);
**move reply→work→hold** → история 4 записи (createdLocal + 3 move-записи со stage-ключами reply/work);
комментарий (пустой 400 «Пустой комментарий» + текст `{comments}`); ссылки add (https-префикс/name=url,
http:// сохранён) + DELETE одной.
6. **hold + POST reminder {at: now1 мин}** → **БЕЗ ручного POST /admin/tick** фоновый 30-с проход
`StorageTickScheduler` за ~20 с: psql `ReminderFired=true`, SSE-подписчику пришло ровно одно
`event: reminder_due` `data:{"id":…,"title":"T13 Hold Card","stage":"hold"}` → move hold→ready:
напоминание снято (reminder null в ответе, psql ReminderAt NULL/Fired=false), история 5 записей.
7. **Файлы**: upload 2 (tz.pdf → document/Документ, photo.png → image/Изображение, size = байты) → мета
в карточке (`files` 2 записи), объекты на диске по objectKey (`data/attachments/projects/<id>/…`),
`FilesJson` содержит objectKey → download tz.pdf (200, `attachment`, octet-stream, Content-Length,
байты совпадают `cmp`) → DELETE photo.png ({ok}, мета без файла, объект удалён с диска, download
удалённого → 404).
8. `GET /api/projects` — список 2 карточек, первая = локальная (**UpdatedAt DESC**); локальную в
rejected → `clear-rejected` `{ok,cleared:1}` (повторный → cleared:0); карточка из лида цела;
psql: в ProjectCards осталась 1 строка.
9. **psql partial UNIQUE**: вставка второго проекта с тем же `LeadId` → duplicate key
`IX_ProjectCards_LeadId` (ошибка). logout → GET /projects 401. Лог Api без исключений.
10. Финал: приложение остановлено; dev-БД очищена (Cards/ProjectCards/LeadComments/CardMoves/
DedupEntries/QueueItems/RejectedItems = 0, настройки не тронуты), `data/attachments` пуст,
схемы/таблицы/индексы/настройки на месте, `deal-minio` оставлен поднятым (dev-стек этапа 6).
## Что сделано (кроме приёмки — код/конфиги не менялись)
- Техдок `docs/technical/Техническая-документация-Дейл.md`: §13 — заголовок/интро на этап 5,
§13.1 Postgres — deal-minio (:9000/:9001, бакет deal-files лениво, DEAL_MINIO_*); новый §4e
«Эндпоинты этапа 5 (Projects/„Выбранные“)» (таблица ProjectCards + partial UNIQUE LeadId, стадии,
take-семантика, 16 эндпоинтов, комментарии/ссылки/файлы/напоминания, SSE reminder_due, Local/MinIO,
исключённые GET /reminders и DELETE /{id}); §4c — пометка о снятой boot-заглушке /projects (остался
/tg/status); §5 (psql-ожидания: ProjectCards и ключевые колонки); §6 (620 PASS, финальная приёмка
75/75); §11 — блок «Выполнено на этапе 5» + актуализированы TODO (осталась только /tg/status;
список активных напоминаний и DELETE карточки — сознательно не реализованы, Ruling 9).
- Roadmap `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: этап 5 перенесён в «Выполнено»
(задачи 113, 620 PASS, curl 75/75; ограничения: реальные ai/telegram/ml и discovery — этап 6,
оператор/лимиты/админка и мульти-аренда MinIO — этап 7), заголовок «актуально на конец этапа 5»,
из «Оставшихся этапов» блок этапа 5 удалён.
- Ledger `.superpowers/sdd/deal-stage5-projects/progress.md`: Task 13 complete + `[x]`.
## Находки/решения приёмки
- **`DELETE /api/projects/{id}` → 405, а не 404**: путь совпадает с зарегистрированными GET/PATCH
`/{cardId}`, поэтому ASP.NET Core отвечает Method Not Allowed. «Маршрута DELETE нет» подтверждено
(Ruling 9); в отчёте и техдоке зафиксировано фактическое поведение 405 (план допускал формулировку
«404 маршрута нет» — семантика та же: эндпоинт не реализован). `GET /api/projects/reminders`
(путь `{cardId}=reminders`) → 404 «Карточка не найдена».
- **Записи истории смены стадии** несут ключ `stage` (не `type:"stage"`): wire {id, at, stage}, 1:1 с
прототипом; в сценарии история проверяется подсчётом h_-записей и наличием move-записей reply/work.
- **psql-конкатенация boolean** даёт `taken|false` (не `taken|f`) — проверка адаптирована.
- Title карточки напоминания — ASCII «T13 Hold Card» (ограничение Windows-native curl, как T11/T12);
русские строки проверялись в ответах сервера (take-комментарий «Взял в работу из лида.», 404-детали).
- Напоминание сработало фоновым циклом за ~20 с (поллинг psql, ручной tick не вызывался); событий
`reminder_due` за приёмку ровно одно.
## Concerns для следующих этапов
- Приём входящих — только demo-источники (simulate-lead/ingest) до gRPC-ингресса telegram-service
(этап 6); контракт take/Projects стабилен (Ruling 5).
- Файлы в сквозной приёмке проверены в Local-режиме (дефолт); MinIO-режим (deal-minio) проверен
live-проверкой Task 6 — MinIO-ветки download (Content-Type из объекта) ждут этап-7 контура.
- Dev-БД оставлена пустой (карточки/лиды/очередь/отсев = 0; схемы/таблицы/индексы/настройки на
месте) — этап 6 может начинаться с чистого состояния; deal-minio поднят.
@@ -0,0 +1,53 @@
# Task 2 — «Модуль Projects: стадии, DTO карточки, порт IProjectStore, реестр» — отчёт
Статус: **DONE** (build 0/0, тесты 535/535 PASS, стадии 1:1 с constants.py PIPELINE_STAGES, модуль чист — без EF/HTTP, реверс-зависимостей нет).
## Файлы
Все — `src/core/Deal.Modules.Projects/` (namespace `Deal.Modules.Projects.Application[.Models]`; 1 тип = 1 файл, XML-doc, комментарии на русском).
### Каталог стадий (Application)
| Файл | Содержание |
|---|---|
| `Application/ProjectStage.cs` | `sealed record ProjectStage(string Id, string Name, string Color, bool Terminal)` — стадия канбана «Выбранных» (не сущность). |
| `Application/ProjectStages.cs` | Каталог 9 стадий в порядке planned→rejected (1:1 constants.py PIPELINE_STAGES L1727 / api-map §4.4): planned Запланировано `#818cf8`, reply Отклик `#38bdf8`, agree Согласование `#a78bfa`, work В работе `#fbbf24`, review Проверка `#f97316`, ready Готово `#4ade80`, hold Отложено `#94a3b8`, finished Выполнено `#2bd576` (terminal), rejected Отклонено `#ff6b6b` (terminal) + `Contains(stage)`. |
| `Application/ProjectIdPrefixes.cs` | Префиксы id (Ruling 11): Card `pr_`, Link `pl_`, File `pf_`, History `h_`; комментарий — общий `KanbanIdPrefixes.Comment` (`cm_`) — закомментирован cref-ом, отдельной константы нет. |
### DTO (Application/Models, record — camelCase наружу)
| Файл | Форма |
|---|---|
| `ProjectFileDto.cs` | {id `pf_`, name, size (long), kind, label, objectKey} — §4.3 L292, Ruling 4. |
| `ProjectLinkDto.cs` | {id `pl_`, name, url}. |
| `ProjectHistoryEntryDto.cs` | {id `h_`, at (epoch-ms), type\|stage}; nullable-поля Type/Stage с `JsonIgnore(WhenWritingNull)` (wire — {id,at,type} либо {id,at,stage}, Ruling 7) + фабрики `Created(at, local)`/`Moved(at, stage)` (id генерирует модуль — Kanban `PrefixId`). |
| `ProjectReminderDto.cs` | {at} — объект reminder карточки (Ruling 3). |
| `ProjectCardDto.cs` | §4.3: id/stage/local/leadId/title/summary/stack/budget (`CardBudgetDto?` Kanban)/contact/comments (`CardCommentDto[]` Kanban)/links/files/tzText/history/reminder/createdAt/updatedAt (CreatedAtMs/UpdatedAtMs → epoch-ms, JsonPropertyName createdAt/updatedAt). |
| `ProjectCardRow.cs` | Полная запись для `CreateAsync` (write-модель: id готов, JSON-массивы типизированы — сериализует адаптер; CreatedAt/UpdatedAt проставляет хранилище UTC-now; ReminderAt=null/fired=false — 1:1 _insert L71100). |
| `ProjectCardPatch.cs` | Частичная правка (title/summary/contact/tzText/stack/budget/comments/links/files); null = «не менять», JSON-поля — полная замена (конвенция BoardPatchDto). |
| `ProjectReminderDueDto.cs` | Мини-DTO {id,title,stage} — возврат `ListDueAsync`/SSE reminder_due (Ruling 3/8). **Добавлен сверх списка файлов Task 2**: тип нужен сигнатуре порта (`ListDueAsync` → мини-DTO), в плане фигурирует в Task 10. |
### Порт и реестр
| Файл | Содержание |
|---|---|
| `Application/IProjectStore.cs` | Порт (эталон IKanjStore): методы 1:1 с планом и Self-Review 3 в том же порядке — `ListAsync(stage?)`, `GetAsync`, `GetByLeadAsync`, `CreateAsync(row)`, `PatchAsync(cardId, patch) → bool`, `MoveStageAsync(cardId, stage, historyEntry, atMs) → bool` (стадия + история + сброс reminder + bump UpdatedAt), `SetReminderAsync(cardId, atMs)`, `ClearReminderAsync(cardId)`, `ClearStageAsync(stage) → int`, `ListDueAsync(now) → IReadOnlyList<ProjectReminderDueDto>`, `MarkFiredAsync(ids)`, `ClearExpiredAsync(now) → int`, `RemoveAsync(cardId)`; все с `CancellationToken`, XML-doc со ссылками на projects.py. |
| `Application/ProjectsModuleRegistrar.cs` | Каркас `AddProjectsModule()` — пустая цепочка (сервисы T4/T5/T7 добавляются по мере появления). |
### Изменён
- `Deal.Modules.Projects.csproj` — ProjectReference на `Deal.Modules.Settings` и `Deal.Modules.Kanban` (Contracts/SharedKernel уже были) + `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11 (как Kanban/Settings). Циклов нет: Kanban/Settings/Contracts о Projects не знают (проверено).
## Валидация
- `dotnet build Deal.sln` из `src/core`: Предупреждений 0, Ошибок 0.
- `dotnet test tests/Deal.Tests.Unit --no-build`: 535/535 PASS (MarkerTests в составе).
- Модуль чист: скан модуля на EF/Npgsql/Http (EntityFrameworkCore|Npgsql|AspNetCore|HttpClient|System.Net.Http) по *.cs — 0 в коде; единственные вхождения — XML-doc упоминания `Deal.Infrastructure` в IProjectStore.cs/ProjectsModuleRegistrar.cs (эталон: такие же doc-упоминания в KanbanModuleRegistrar) + сгенерированный `obj/.../GlobalUsings.g.cs` (артефакт сборки, не исходник).
- Реверс-проверка: скан Kanban/Settings/Contracts (*.cs + *.csproj) на «Deal.Modules.Projects» — 0 совпадений.
## Отклонения и решения
- `ProjectReminderDueDto.cs` создан в Task 2 (порт ссылается на тип; в плане файл не перечислен, но появляется в Task 10 — порт без него не компилируется). Тривиальный, без логики.
- Id записи истории генерируется прямо в фабриках `Created/Moved` через Kanban `PrefixId.New(ProjectIdPrefixes.History)` — переиспользование публичного генератора владельца (эталон PipelineIdPrefixes), случайную часть даёт Kanban. Тесты при желании могут переопределить id через `with { Id = ... }`.
- `ProjectCardPatch.Budget` = null означает «не менять» (конвенция BoardPatchDto); явная очистка бюджета телом PATCH (в прототипе budget не словарь → обнуление, patch_card L174179) — вопрос слоя эндпоинта/сервиса Task 8 (отражено в XML-doc патча).
- Имена методов порта — ровно по Self-Review 3 (список для Task 3 совпадает без расхождений).
@@ -0,0 +1,81 @@
# Task 3 — «EF-адаптер ProjectStore + DI» — отчёт
Статус: **DONE** (build 0/0, тесты 535/535 PASS, функциональная dev-проверка адаптера на дефолтном тенанте 48/48, psql-проверка строки/JSON — зелёная, таблица ProjectCards возвращена в пустое состояние).
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 3 (L250269), Rulings 1/2/3/7/11;
эталоны `KanbanStore.cs`/`PipelineStore.cs`/`SettingsStore.cs`; фактические сигнатуры порта — `Deal.Modules.Projects/Application/IProjectStore.cs` (Task 2) 1:1 со Self-Review 3.
## Файлы
### Создан
- `src/core/Deal.Infrastructure/Persistence/Repositories/ProjectStore.cs` — реализация `IProjectStore` на
`TenantDbContext` (primary constructor, как `SettingsStore`). Все 13 методов порта 1:1 с
`projects.py`: `ListAsync(stage?)` / `GetAsync` / `GetByLeadAsync` / `CreateAsync` / `PatchAsync`
bool / `MoveStageAsync` → bool / `SetReminderAsync` / `ClearReminderAsync` / `ClearStageAsync` → int /
`ListDueAsync``{id,title,stage}` / `MarkFiredAsync` / `ClearExpiredAsync` → int / `RemoveAsync`.
### Изменены
- `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs``AddDealPersistence()`: добавлен
`AddScoped<IProjectStore, ProjectStore>()` (+ using модуля Projects; XML-doc списка адаптеров дополнен).
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — ProjectReference → `Deal.Modules.Projects`
(циклов нет: Projects зависит от Settings/Kanban/Contracts, о Infrastructure не знает).
## Реализация
- **Чтения** — `AsNoTracking()`; сортировка списка — `OrderByDescending(UpdatedAt)` (1:1 list_cards
L5863; индекс IX_ProjectCards_UpdatedAt DESC Task 1).
- **Маппинг** вручную (порт не видит EF-сущности): `ToCardDto`/`ToCardEntity` + `ApplyPatch`.
JSON-поля (StackJson/CommentsJson/LinksJson/FilesJson/HistoryJson) — text c JSON camelCase
(конвенция value_json, эталон KanbanStore JsonOptions): запись `ToJson<T>`, чтение `ToJsonList<T>`
(пустая/битая строка → пустой список, как `json.loads(... or "[]")`). Wire-формы 1:1 с §4.3:
комментарий `{id,by,text,time}`, ссылка `{id,name,url}`, файл `{id,name,size,kind,label,objectKey}`,
история `{id,at,type|stage}` (JsonIgnore WhenWritingNull — тип/стадия не смешиваются), бюджет —
`CardBudgetDto|null` из пары (BudgetFrom, BudgetTo, BudgetCur): `BudgetCur == ""` → null (Ruling 11).
- **Времена** — timestamptz (`DateTimeOffset`); наружу epoch-ms (`ToUnixTimeMilliseconds`), на запись —
`FromUnixTimeMilliseconds`. `MoveStageAsync` пишет `updated_at = atMs` переноса (move_stage L210215),
`SetReminderAsync` бампает UpdatedAt (L236243), `ClearReminderAsync` — без бампа (L246247, 1:1).
- **Патч** (`PatchAsync`): null-поле не меняется, JSON-поля — полная замена, в конце bump UpdatedAt
(patch_card L159187); bool = «строка обновлена» (404-семантика сервиса).
- **Move** (`MoveStageAsync`): чтение AsNoTracking (нужна текущая история) → ОДИН `ExecuteUpdate`
(stage + reminder_at=NULL + reminder_fired=false + updated_at + history с добавленной записью).
- **Напоминания**: `ListDueAsync``Stage='hold' AND ReminderAt ≤ now AND ReminderFired=false`
ORDER BY ReminderAt (check_reminders L270275); `MarkFiredAsync` — ReminderFired=true по списку id;
`ClearExpiredAsync` — ReminderAt=NULL+ReminderFired=false по всем протухшим (L266–269, fired не важен).
- **Удаления** — одним statement'ом: `ClearStageAsync`/`RemoveAsync``ExecuteDeleteAsync` (счётчик =
затронутые строки); частичный UNIQUE по LeadId (гонка take) страхует БД (Ruling 1) — адаптер её не дублирует.
- Транзакции не потребовались: каждая операция — одиночный statement/SaveChanges (конвенция этапа 3–4).
## Проверка
1. **Build**: `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit`**535/535 PASS** (юниты на EF-адаптерах не пишем —
конвенция этапа 4; проверка функциональная).
3. **Dev-харнесс** (временный проект вне sln на реальном Postgres deal-postgres :5433, схема дефолтного
тенанта, удалён после прогона): через `IProjectStore` (DI `AddDealPersistence` + scoped
`TenantDbContext`) созданы локальная карточка и карточка из лида (все JSON-поля, бюджет, LeadId),
проверены чтения/списки (UpdatedAt DESC, фильтр stage), патч всех полей (включая полную замену
JSON-массивов и обнуление from бюджета), move + запись истории + updated_at=atMs + сброс reminder,
напоминания (set/clear/due/fired/clear-expired/сброс при move), clear-stage, remove — **48/48 ok**.
4. **psql** (схема `tenant_00000000000000000000000000000001`): строка `ProjectCards` подтверждена —
`Stage='hold'`, camelCase-JSON в text-полях (кириллица хранится `\u`-эскейпами .NET — см. ниже),
декодирование операторами json (`->>`: текст/автор/метки читаются как «Комментарий после патча.»/
«Вы»/«Документы»), бюджет EUR to=300, ReminderAt NULL/ReminderFired=f, `UpdatedAt > CreatedAt` (bump).
После проверки dev-строки удалены — таблица пуста (count 0).
5. Диагностики изменённых файлов — без ошибок/предупреждений.
## Решения и замечания
- **PatchAsync — отслеживаемая сущность + SaveChanges** (эталон `KanbanStore.UpdateColumnAsync`), а не
условный `ExecuteUpdate`: в EF Core 10 публичный тип `SetPropertyCalls<TEntity>` (EF 7–9) отсутствует
в сборке Relational (проверено по DLL/компилятору) — условный builder-сеттер не собрать без имени
типа. SaveChanges пишет один UPDATE только изменённых колонок — семантика patch_card 1:1.
- **MoveStageAsync — AsNoTracking + один ExecuteUpdate, независимо от change-трекера.** Первый прогон
харнесса поймал ловушку: `CreateAsync` оставляет строку отслеживаемой, а `SetReminderAsync`/
`ClearExpiredAsync` пишут через `ExecuteUpdate` (мимо трекера); последующий tracked-move видел
устаревший `ReminderAt` (null из создания) и НЕ включал колонку в UPDATE — напоминание «оживало».
Текущая реализация читает AsNoTracking и пишет одним statement'ом — от трекера не зависит (баг был
только в харнессе/адаптере, до эндпоинтов не доходил; зафиксировано как решение).
- **JSON в БД**: не-ASCII хранится `\uXXXX`-эскейпами (дефолтный encoder System.Text.Json, как в
существующем KanbanStore) — семантически 1:1 с wire-формой, на чтении разбирается в исходный текст
(проверено в харнессе 48/48 и psql `->>`); python-прототип писал ensure_ascii=False (косметика байт,
на контракт не влияет — решили не отступать от эталона хранилищ).
- Модуль Projects не тронут (кроме csproj-ссылки Infrastructure); циклов зависимостей нет.
@@ -0,0 +1,123 @@
# Task 4 — «Взять в работу»: порт Kanban MarkTakenAsync + ProjectsService — отчёт
Статус: **DONE** (build 0/0, тесты 552/552 PASS — 535 этапа 4 + 17 новых ProjectsServiceTests).
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 4 (L271299), Rulings 1/3/5/6/7/9/10/11;
источники `projects.py` L103156/L159231, `projects_routes.py` L78121, api-map §3.5/§4.3/§4.4;
эталоны `CardsService`/`IKanjStore`/`LeadMoveResultDto`/`KanbanStore.UpdateSeenAsync`.
## Файлы
### Изменены
- `src/core/Deal.Modules.Kanban/Application/IKanjStore.cs` — метод порта `MarkTakenAsync(string cardId,
CancellationToken ct) → Task<bool>` (секция «Карточки», после GetCardAsync): UPDATE Cards SET Col='taken',
IsNew=false WHERE Id=? — «взять в работу» (take_lead_to_projects L155, Ruling 5); XML-doc: журнал
CardMoves/архивные поля/matchHits не трогает; потребитель — модуль Projects, реверс-зависимостей нет.
- `src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs` — реализация `MarkTakenAsync`
одним `ExecuteUpdate` (col=taken, is_new=false), возврат `affected == 1` (эталон UpdateSeenAsync).
- `src/core/Deal.Modules.Projects/Application/ProjectsModuleRegistrar.cs` — `AddProjectsModule()`
регистрирует `AddScoped<ProjectsService>()` (вызов из Program.cs — Task 8, по плану).
- `src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs` — расширение: `MarkTakenAsync` (1:1 с KanbanStore:
col='taken' + is_new=false), счётчик `MarkTakenCalls`, флаг `FailMarkTaken` (гонка «лид исчез»); класс-док дополнен.
### Созданы
- `src/core/Deal.Modules.Projects/Application/Models/ProjectCardResultDto.cs` — тонкий record-результат
`(string? Error, ProjectCardDto? Card)` (эталон LeadMoveResultDto): Error — 400-строка, Card=null без
Error — 404-семантика; переиспользуется мутациями Tasks 5/7.
- `src/core/Deal.Modules.Projects/Application/Models/ProjectLocalCreateDto.cs` — начальные поля ручного
создания: {title, summary, stack?, budget?, contact, tzText?, stage?} (Ruling 6; «ProjectCardPatch-начальные
поля» + stage — в ProjectCardPatch поля stage нет, PATCH его не принимает, projects_routes L3036).
- `src/core/Deal.Modules.Projects/Application/ProjectsService.cs` — чистый сервис (зависимости
IProjectStore + IKanjStore): `ListAsync(stage?)`, `GetAsync`, `CreateLocalAsync`, `TakeLeadAsync`,
`PatchAsync`, `MoveAsync`, `ClearRejectedAsync`.
- `src/core/tests/Deal.Tests.Unit/FakeProjectStore.cs` — in-memory `IProjectStore` (все 13 методов;
поведение 1:1 с EF-адаптером ProjectStore: сортировка/фильтр, часы для CreatedAt/UpdatedAt, patch-семантика
null-полей, move+история+сброс reminder+updated_at=atMs, «выстрелившие» напоминания множеством firedById).
- `src/core/tests/Deal.Tests.Unit/ProjectsServiceTests.cs` — 17 тестов (см. ниже).
## Реализация
- **TakeLeadAsync** (1:1 take_lead_to_projects L127156, Ruling 5): GetCardAsync → null-результат (404 «Лид не
найден» у эндпоинта Task 8); GetByLeadAsync — есть карточка → возврат её (идемпотентность, без вставки);
иначе CreateAsync: stage=planned, local=false, leadId, title/summary/stack/budget/contact из CardDto,
comments=[{id `cm_`, by «Вы», text «Взял в работу из лида.», time «только что»}], history=[{type:"created"}],
tzText=""; затем MarkTakenAsync; false (лид исчез в гонке) → RemoveAsync-откат + null. Журналов/ML/SSE нет.
- **CreateLocalAsync** (Ruling 6): local=true, LeadId=null, title Trim(), stage из тела, если в ProjectStages,
иначе planned; история — `createdLocal` (Ruling 7); пустой title допустим.
- **MoveAsync**: валидация ProjectStages → 400 «Неизвестная стадия» (константа `UnknownStageDetail`);
запись истории {id `h_`, at, stage} + сброс напоминания (Ruling 3: ЛЮБОЙ move) + updated_at=время переноса —
всё в MoveStageAsync хранилища (Task 3); 404 — Card=null.
- **PatchAsync/ClearRejectedAsync**: тонкое делегирование; clear — только стадия rejected (Ruling 9), счётчик.
- Чтение (List/Get) — pass-through; стадии/времена/id по Rulings 1/11 (PrefixId; `pr_`/`h_` из
ProjectIdPrefixes, комментарий — KanbanIdPrefixes.Comment).
## Проверка
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. `dotnet test tests/Deal.Tests.Unit` — **552/552 PASS** (535 этапа 4 + 17 новых: 2 чтение, 3 локальное
создание (trim/stage-fallback/пустой title), 4 take (лид не найден; копия полей + col=taken + is_new=false +
MarkTaken вызван; дубль → существующая без вставки; сбой MarkTaken → откат), 2 patch (поля+budget+stack и
null-не-меняет+bump; 404), 3 move (история+сброс reminder+updated_at=atMs; 400 неизвестная стадия без
изменений; 404), 2 clear-rejected (только rejected + счётчик; пустая стадия → 0)). MarkerTests PASS.
3. Диагностики изменённых файлов — без ошибок/предупреждений.
## Решения и замечания
- **Параллельная гонка двух take (Ruling 5)**: данные страхует частичный UNIQUE ProjectCards.LeadId
(Task 1) — вторая вставка падает (DbUpdateException). План (Task 4 L282) предлагает «поймать DbUpdateException
и перечитать GetByLeadAsync» — в чистом модуле Projects (без EF-ссылки, Global Constraints) EF-исключение
поймать невозможно; прецедент кодовой базы — KanbanStore.AddCommentAsync (task-3-report этапа 3: гонка с
удалением → DbUpdateException наружу, целостность держит БД, 404-семантику даёт сервис пред-чтением). Поэтому
в сервисе реализована идемпотентность последовательного дубля (GetByLeadAsync, покрыта тестом), а гонка
оставлена на UNIQUE-индекс (состояние консистентно: одна карточка, лид помечен); зафиксировано в XML-doc
TakeLeadAsync. Если понадобится «graceful» параллельный take — кандидат: перевод конфликта в адаптере
ProjectStore в модульное исключение (решение за ревью, вне файл-листа Task 4).
- **CreateLocalAsync принимает отдельный `ProjectLocalCreateDto`** (а не ProjectCardPatch): тело создания
(Ruling 6) несёт `stage`, которого в ProjectCardPatch нет (PATCH стадию не принимает — projects_routes L3036),
а поля comments/links/files в тело POST /api/projects не входят.
- **Результаты мутаций** — по конвенции CardsService/LeadsEndpoints: 400-тексты — константы сервиса и Error
record-результата; 404 («Карточка не найдена»/«Лид не найден») — null-результат, текст у эндпоинта (Task 8).
- `AddProjectsModule` теперь регистрирует ProjectsService, но в Program.cs вызовется в Task 8 (Api → модуль
Projects по Ruling 2) — сейчас модуль никто не резолвит, изменений DI-графа рантайма нет.
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах; константы вместо литералов (planned/rejected/
«Взял в работу из лида.»/«Вы»/«только что»); без регионов; комментарии на русском.
## Fix после ревью (Findings 12)
### Finding 1 (Important) — PATCH с явным budget:null (очистка бюджета)
**Проблема:** типизированный `ProjectCardPatch.Budget==null` не отличает «ключ отсутствует» от «null» —
явная очистка бюджета телом (фронт реально шлёт `{budget: null}`: ProjectDrawer.vue saveBudget, когда оба поля
пусты) молча не применялась. Типизированный биндинг эндпоинта (Task 8) потерял бы presence.
**Исправление (уровень сервиса, эталон SettingsService.ApplyPatchAsync / SettingsEndpoints PATCH):**
- `ProjectsService.PatchAsync` переведён на presence-aware сигнатуру `PatchAsync(string cardId,
IReadOnlyDictionary<string, JsonElement> body, CancellationToken ct)`: учитывается ПРИСУТСТВИЕ ключа;
эндпоинт Task 8 десериализует тело в `Dictionary<string, JsonElement>` и передаёт как есть (готовый метод).
- Ключи и семантика значений — 1:1 с прототипом patch_card L159187 и pydantic PatchBody (projects_routes L3036):
title/summary/contact/tzText — JSON-строка (пустая строка очищает текст; null/не-строка → ключ игнорируется —
эталон SettingsService.TryReadText: JSON-null трактуется как отсутствие, а не «None»-строка прототипа);
stack — массив строк или null/не-массив → пустой стек (`_json(value or [])` L172173); budget — объект
{from,to,cur} либо null/не-объект → ОЧИСТКА (`budget не словарь → обнуление` L174–179). Неизвестные ключи
отбрасываются (pydantic PatchBody режет тело до шести ключей).
- Очистка бюджета передаётся хранилищу объектом с пустой Cur (`CardBudgetDto(null,null,"")` = «бюджета нет»,
Ruling 11) — семантика хранилища `Budget != null → писать from/to/cur` сохранена; XML-doc ProjectCardPatch
уточнён (раздел «слоя сервиса» больше не откладывается на Task 8).
- Тесты переписаны под тело PATCH: присутствующие ключи меняют поля (+bump UpdatedAt, отсутствующий ключ не
трогает поле, пустая summary очищает текст); `budget:null` очищает бюджет (карточка и хранилище — null);
`stack:null` очищает стек; неизвестные ключи (stage) игнорируются; null текстового ключа не применяется;
404 на отсутствующей карточке. Хелпер `PatchBody((key, value)...)` сериализует пары в JsonElement
(эталон PATCH /settings).
- `FakeProjectStore.PatchAsync` приведён к семантике адаптера: патч бюджета с пустой Cur хранит строку
«без бюджета» → наружу `Budget=null` (раньше фейк оставлял непустой объект CardBudgetDto(null,null,"")).
### Finding 2 (Minor) — история move: append, а не replace
**Проблема:** тест move сидел на карточке с пустой историей — `Assert.Single` не отличал добавление от замены.
**Исправление:** тест `Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt`: карточка посеяна с записью
создания + напоминанием; два move подряд (hold → work → review). Проверяется, что история НЕ заменяется, а
КОПИТСЯ: 3 записи [created, work, review] в порядке переносов (Ruling 7), id всех записей `h_`, reminder снят,
updated_at = время последнего переноса (At последней записи), хранилище хранит ту же накопленную историю.
**Проверка после фикса:** build 0/0; ProjectsServiceTests 21/21 PASS; полный `dotnet test` — 556/556 PASS
(535 этапа 4 + 21 новых); диагностики изменённых файлов чистые.
@@ -0,0 +1,66 @@
# Task 5 — Комментарии и ссылки (ProjectsService) + тесты — отчёт
Статус: **DONE** (build 0/0, тесты 567/567 PASS — 556 этапа 4/5(T4) + 11 новых ProjectsServiceTests).
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 5 (L299312), Rulings 7/11;
источники `projects.py` add_comment L194199, `projects_routes.py` L124150, api-map §3.5 L166168/§4.3;
эталон `CardsService.AddCommentAsync`/`AddCommentResultDto`/`ProjectCardResultDto`.
## Файлы
### Изменены
- `src/core/Deal.Modules.Projects/Application/ProjectsService.cs` — методы Task 5:
`AddCommentAsync(cardId, text, ct)``ProjectCommentResultDto`, `AddLinkAsync(cardId, name, url, ct)` и
`RemoveLinkAsync(cardId, linkId, ct)``ProjectCardResultDto`; константы 400 `EmptyCommentDetail`
(«Пустой комментарий») и `EmptyLinkDetail` («Пустая ссылка»); шапка класса дополнена Task 5.
- `src/core/tests/Deal.Tests.Unit/ProjectsServiceTests.cs` — +11 тестов (комментарии 4, ссылки 7).
### Создан
- `src/core/Deal.Modules.Projects/Application/Models/ProjectCommentResultDto.cs` — тонкий record-результат
`(string? Error, IReadOnlyList<CardCommentDto>? Comments)` (эталон AddCommentResultDto Kanban): Error — 400
«Пустой комментарий»; Comments=null без Error — 404-семантика; ответ эндпоинта оборачивает список в
`{"comments": [...]}` (api-map §3.5 L166). Новый файл вне файл-листа плана: результат «список comments» не
выражается существующим `ProjectCardResultDto` (он несёт карточку), а тип Kanban завязан на семантику
журнала LeadComments — по код-стайлу (1 тип = 1 файл) заведён свой record модуля.
## Реализация
- **AddCommentAsync** (1:1 add_comment L194199 + route L124128): текст Trim; пустой → 400 (как route,
валидация ДО сервиса/карточки); карточки нет → Error=null/Comments=null (404 — прототип на этом пути падает
500, .NET отвечает корректным 404); новая запись {id `cm_` — KanbanIdPrefixes.Comment, by «Вы», text после
Trim, time «только что»}; запись — `IProjectStore.PatchAsync` полной заменой CommentsJson (append в конец —
порядок сохраняется), хранилище бампает UpdatedAt (patch_card L186). В историю не пишется (Ruling 7).
- **AddLinkAsync** (1:1 route L133143): порядок прототипа — сначала _card_or_404 (404-результат), затем url
Trim → пустой → 400 «Пустая ссылка»; без префикса http:// или https:// → «https://» + url (L139140);
запись {id `pl_` — PrefixId (Ruling 11) вместо `pl_{card}_{n}` прототипа L142, name: name.Trim() или url,
url}; ответ — карточка после мутации.
- **RemoveLinkAsync** (1:1 route L146150): 404 карточки; фильтрация массива по id — неизвестный id не ошибка
(список без изменений); запись PatchAsync; ответ — карточка.
- Счётчик-значок links на карточке — длина массива `links` ProjectCardDto (Ruling 4/11) — уже обеспечен
формой ProjectCardDto Task 2/4; новые методы только мутируют массив.
## Проверка
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. `dotnet test tests/Deal.Tests.Unit`**567/567 PASS** (556 + 11 новых: комментарий — форма {id `cm_`, by
«Вы», text Trim, time «только что»} + bump UpdatedAt; append двух комментариев сохраняет порядок; пустой/
пробельный текст → 400 «Пустой комментарий» без записи; 404 карточки; ссылка — url без схемы → https:// и
name=url; http-схема сохраняется + name Trim; пустой url → 400 «Пустая ссылка» без записи; 404 карточки
раньше валидации url; удаление по id (только целевая, bump UpdatedAt); неизвестный id → без ошибки;
404 карточки). MarkerTests PASS.
3. Диагностики изменённых файлов — без ошибок/предупреждений.
## Решения и замечания
- **Форма хранения/возврата — по Rulings 1/11**: комментарии/ссылки живут JSON-массивами в колонках
CommentsJson/LinksJson карточки ProjectCards (отдельных таблиц нет); в ProjectCardDto — массивы `comments`
(форма {id,by,text,time}, DTO Kanban CardCommentDto) и `links` (форма {id,name,url}); эндпоинты
POST `/{cardId}/comments`, POST/DELETE `/{cardId}/links` — в Task 8 (там же обёртки `{comments: [...]}` и
маппинг 404/400); сервисные методы готовы к 1:1-биндингу.
- **Порядок проверок**: для ссылок повторён прототип (404 карточки раньше 400 пустого url — route L135 перед
L137–138); для комментариев — 400 пустого текста раньше 404 (route L126–127 перед вызовом сервиса; эталон
CardsService.AddCommentAsync).
- **Рейс «карточка удалена между чтением и записью»**: PatchAsync=false → 404-результат (как MoveAsync);
после успешной записи карточка перечитывается (эталон TakeLeadAsync/MoveAsync) — исчезновение между
патчем и чтением — InvalidOperationException (недостижимо без параллельного удаления).
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах; константы вместо литералов
(«Пустой комментарий»/«Пустая ссылка»/http/https-схемы); без регионов; комментарии на русском.
@@ -0,0 +1,117 @@
# Task 6 — Файлы: порт IFileStorage, Local/MinIO-адаптеры, FileKindDetector, compose-minio, DI — отчёт
Статус: **DONE** (build 0/0, тесты 584/584 PASS — 567 этапа 15 + 17 новых: FileKindDetectorTests 8,
LocalFileStorageTests 9; compose config валиден; запуск Api — LocalFileStorage; live-check MinIO пройден).
План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 6 (L314340), Ruling 4;
источники `backend/app/services/object_store.py` L26108, `backend/app/services/files.py` L1345;
эталоны `IAiClassifier` (Contracts-порт), `CbrRateSource`/`EncryptionKeyProvider` (Infrastructure-стиль),
`ProjectsServiceTests`/`LocalFileStorageTests` (тесты).
## Файлы
### Созданы (Contracts)
- `src/core/Deal.Contracts/Integrations/IFileStorage.cs` — внешний порт файлового хранилища ровно по
Ruling 4 (object_store.py L61107): `PutAsync(objectKey, Stream, contentType, ct) → objectKey`,
`GetAsync(objectKey, ct) → Stream?` (null — объекта нет), `DeleteAsync(objectKey, ct)`. XML-doc: objectKey —
opaque, формат `projects/{cardId}/{unixMs}_{safeName}`; единственный бакет и отсутствие tenant-префикса —
как в прототипе (мульти-аренда объектного хранилища — этап 7 SaaS).
- `src/core/Deal.Contracts/Integrations/Models/FileMeta.cs` — record `FileMeta(Key, Size, ContentType)`
(тип-описатель объекта порта, по требованию задачи; см. «Решения и замечания»).
### Созданы (модуль Projects)
- `src/core/Deal.Modules.Projects/Application/ProjectFileKind.cs` — record `ProjectFileKind(Kind, Label)`
(эталон ProjectStage): результат детектора, wire-поля ProjectFileDto.kind/label.
- `src/core/Deal.Modules.Projects/Application/FileKindDetector.cs` — чистый детектор
`Detect(name, mime) → ProjectFileKind`: MIME-префиксы image/|video/|audio/ → kind, иначе расширение по
наборам 1:1 files.py KIND_BY_EXT L1319; неизвестное → other/«Файл»; метки 1:1 KIND_LABELS L2128.
Константы категорий (ImageKind=«image» … OtherKind=«other») публичные. Сравнение регистронезависимо
(HTTP content-type), расширение — часть имени после последней точки в нижнем регистре (как python `.lower()`).
### Созданы (Infrastructure, `Integrations/Storage/`)
- `StorageOptions.cs` / `LocalStorageOptions.cs` (Local: Root?) / `MinioStorageOptions.cs`
(Minio: Endpoint/AccessKey/SecretKey/Bucket=deal-files/Secure; 1 тип = 1 файл).
- `LocalFileStorage.cs` — root-каталог (дефолт `data/attachments` под ContentRoot резолвит регистратор):
Put — mkdir родителя + `CopyToAsync` (поток не буферизуем — длина не нужна); Get — `FileStream|null`;
Delete — удаление файла, отсутствующий — no-op. Путь из objectKey строится безопасно (object_store.py
`_local_path` L54–79): сегменты по `/` (`\` нормализуется — защита не зависит от ОС), сегменты `.`/`..`
запрещены, полный путь обязан лежать внутри root (контроль после `GetFullPath`) — тест «`..` не выходит
за root».
- `MinioFileStorage.cs` — Minio .NET SDK **7.0.0** (NuGet, единственный новый пакет этапа): клиент строится
в ctor без сети (`WithEndpoint/WithCredentials/WithSSL(false)/Build`); бакет проверяется/создаётся ЛЕНИВО
при первом put под `SemaphoreSlim`-gate (object_store.py L2651; сбой проверки — warning, put упадёт);
Put буферизует поток в MemoryStream (MinIO нужна длина; прототип и так держит байты в памяти L67–73);
Get — `GetObjectAsync` с `WithCallbackStream` → MemoryStream (в 7.0 API содержимое приходит в callback,
метод возвращает ObjectStat); `ObjectNotFoundException` → null; Delete гасит `MinioException` warning-логом
(remove L96108). Оба адаптера — `ToString()`-описание для стартового лога Api.
- `FileStorageRegistrar.cs``AddDealFileStorage(IConfiguration, contentRootPath)`: читает секцию
`Storage`; Minio заполнена (Endpoint + AccessKey/SecretKey) → `MinioFileStorage` (singleton, фабрика с
`ILogger<MinioFileStorage>`), иначе → `LocalFileStorage` (root: `Storage:Local:Root` относительный — под
ContentRoot, абсолютный — как есть, пусто — `data/attachments`). Секция (appsettings/env
`Storage__Minio__*`) приоритетнее; незаданные поля Minio заполняются env-алиасами `DEAL_MINIO_ENDPOINT`/
`_ACCESS_KEY`/`_SECRET_KEY`/`_BUCKET`/`_SECURE` (аналог LEADRADAR_MINIO_* config.py).
### Созданы (тесты, `tests/Deal.Tests.Unit/`)
- `FileKindDetectorTests.cs` — 8 тестов: расширения по всем пяти наборам KIND_BY_EXT (kind+метка),
mime-image поверх неизвестного расширения, video/audio mime, неизвестное → other/«Файл» (в т.ч. без точки
и «trailing-dot.»), регистронезависимость.
- `LocalFileStorageTests.cs` — 9 тестов (временный каталог, удаляется в Dispose): put/get round-trip байт,
вложенные каталоги из objectKey, get отсутствующего → null, delete + повторный delete no-op,
traversal `../..` (в т.ч. через `\` и сегмент `.`) → ArgumentException и ничего не записано вне root
(проверяется реальный путь наивного `Path.Combine`), пустой/«///» ключ → ArgumentException.
### Изменены
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj``<PackageReference Include="Minio" Version="7.0.0"/>`
(комментарий: единственный новый пакет этапа файлов; Ruling 4/план Task 6).
- `src/core/Deal.Api/Program.cs` — вызов `AddDealFileStorage(builder.Configuration,
builder.Environment.ContentRootPath)` (после AddDealIntegrations) + стартовый лог режима
(`app.Logger.LogInformation("Файловое хранилище: {FileStorage}", …)` после `Build()`) — приёмка Task 6.
- `deploy/compose.dev.yml` — сервис `minio` (container_name `deal-minio`, image minio/minio, порты
`9000:9000`/`9001:9001` — проверено, что свободны, комментарий «если заняты LeadRadar-minio — 9100/9101»,
MINIO_ROOT_USER/PASSWORD=deal_minio/deal_minio_secret, volume `deal_minio_data`, command
`server /data --console-address ":9001"`) + volume; комментарий: бакет deal-files создаёт приложение лениво
при первом put (Ruling 4), init-контейнер не нужен.
## Проверка
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. `dotnet test Deal.sln` — **584/584 PASS** (567 + 17 новых; таймаута/флейков нет). MarkerTests PASS.
3. Запуск Api (`dotnet run --project Deal.Api`, Development) — стартовый лог:
`Файловое хранилище: LocalFileStorage (root: C:\telbase\src\core\Deal.Api\data/attachments)` — Local-режим
по умолчанию подтверждён; приложение стартовало (слушает :5191), процесс остановлен по таймауту, порт свободен.
4. `docker compose -f deploy/compose.dev.yml config --quiet` — OK (конфиг валиден).
5. **Live-check MinIO** (порты 9000/9001 свободны: `docker ps` — только deal-postgres; netstat пуст):
`docker compose up -d minio` → deal-minio поднят; временный скрипт против реального `MinioFileStorage`
(endpoint localhost:9000, креды deal_minio/deal_minio_secret, бакет deal-files): put → get round-trip байт
→ get отсутствующего = null → delete → get null — **OK** (бакет создан адаптером лениво при первом put).
Контейнер оставлен поднятым (как deal-postgres) для ручной MinIO-приёмки Tasks 7–9; временные скрипты удалены.
## Решения и замечания
- **Порт — минимальный по Ruling 4** (Put/Get/Delete; `GetAsync → Stream?` = «объекта нет», без ExistsAsync —
в плане Exists нет, отсутствие выражается null). `FileMeta` (Key, Size, ContentType) добавлен как тип-описатель
по явному требованию задачи, но потребителя в задачах этапа не имеет: метаданные вложений живут в
`ProjectCards.FilesJson` (запись {id,name,size,kind,label,objectKey}, Ruling 1), download отдаёт фиксированный
`application/octet-stream` (Ruling 4). При ревью: либо удалить, либо задействовать в Task 9 (например,
Content-Length/Content-Type ответа через StatObject/FileInfo).
- **objectKey — НЕ с tenant-префиксом**: формат `projects/{cardId}/{unixMs}_{safeName}` 1:1 с
object_store.put L65 и Ruling 4 («единственный бакет и отсутствие tenant-префикса — как в прототипе;
мульти-аренда объектного хранилища — этап 7 SaaS»). Tenant-префикс из общего прототипа LeadRadar к этому
плану не применяется; key строит ProjectFilesService (Task 7), хранилище ключ только безопасно резолвит.
- **Minio SDK 7.0.0** (в NuGet-кэше; версия не зафиксирована планом). API 7-го SDK отличается от 6.x:
`GetObjectAsync` отдаёт содержимое через `WithCallbackStream` (async-перегрузка) и возвращает `ObjectStat`;
fluent-конфигурация — расширения `MinioClientExtensions` (`WithEndpoint/WithCredentials/WithSSL/Build`).
Учтено в адаптере; зеркалится в report для Task 7/9.
- **Выбор режима и именование env**: план Ruling 4 говорит о секции `Storage:Minio` (env `Storage__Minio__*`);
формулировка задачи упоминала `DEAL_MINIO_*`. Поддержаны оба механизма: секция приоритетнее, алиасы
`DEAL_MINIO_*` заполняют незаданные поля (аналог `LEADRADAR_MINIO_*` прототипа). `Secure` из алиаса парсится
как «true/1».
- **FileKindDetector «по магии» не читает содержимое** — 1:1 с files.py detect L3145 (тип даёт браузерный
content-type + имя файла; в .NET — MIME из multipart и fileName), тесты плана покрывают именно mime/extension.
Категории — wire-kind прототипа image/video/audio/archive/document/other + метки («Изображение»…«Файл»),
на них фронт вешает иконки (§4.3 L292).
- **compose**: сервис по плану (9000/9001, т.к. свободны — LeadRadar-minio в этом docker-контексте не поднят);
fallback 9100/9101 задокументирован комментарием в compose. Бакет создаётся в коде (EnsureBucket на первом
put, Ruling 4) — отдельный mc/init-контейнер не заводили. Обновление README/техдока — в финале этапа (T13), по плану.
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах и классах; именованные константы (никаких
магических строк: дефолтный бакет, путь data/attachments, env-имена, application/octet-stream); без регионов;
комментарии на русском.
@@ -0,0 +1,66 @@
# Task 7 — ProjectFilesService — добавить/удалить файл (мета + объект) — отчёт
Статус: **DONE** (build 0/0; 599/599 PASS — 584 этапов 16 + 15 новых: ProjectFilesServiceTests 14,
LocalFileStorageTests +1 по Ruling T6). План: `docs/superpowers/plans/2026-09-05-deal-stage5-projects.md`
Task 7 (L342358), Ruling 4/11; источники `backend/app/services/files.py` L5794, `object_store.py` L61108,
`projects_routes.py` L155186; эталоны ProjectsService/ProjectsServiceTests (Tasks 4/5), LocalFileStorageTests (T6).
## Файлы
### Создан
- `src/core/Deal.Modules.Projects/Application/ProjectFilesService.cs` — чистый сервис файлов карточек (без
EF/HTTP), зависимости IProjectStore + IFileStorage. API по плану Task 7 (его же потребляет Task 9):
- `AddAsync(cardId, fileName, contentType, Stream content, long size, ct) → ProjectFileDto?` — карточка
читается ДО записи объекта (null → 404 «Карточка не найдена», объект НЕ пишется — приёмка Task 7);
kind через FileKindDetector.Detect (mime → расширение); objectKey = `projects/{cardId}/{unixMs}_{safeName}`
(safeName: path-разделители `/\` и кавычки `"``_`, имя в мета — как прислано, Ruling 4); PutAsync затем
PatchAsync полной заменой FilesJson (дописывание в конец — порядок сохраняется; bump UpdatedAt). Пустое имя
→ «file» (1:1 `f.filename or "file"` L161). Гонка «карточка исчезла после Put» → DeleteAsync объекта +
null (без объектов-сирот).
- `GetEntryAsync(cardId, fileId, ct) → ProjectFileDto?` — мета-запись {id,name,size,kind,label,objectKey}
для download-эндпоинта (Task 9 резолвит поток сам через IFileStorage.GetAsync и мапит 410/404); карточка/
запись не найдены → null (Ruling 4: 404 «Карточка не найдена»).
- `RemoveAsync(cardId, fileId, ct) → ProjectCardDto?` — объект удаляется только когда запись есть и у неё
непустой objectKey (remove_file L92); запись убирается полной заменой FilesJson (не найдена — no-op без
ошибки, эталон RemoveLinkAsync); карточки нет → null (404).
- Лимитов размера/количества НЕ вводим: в прототипе их нет (files.py L5794, routes L155162); ограничение
тела multipart — зона HTTP-слоя (Kestrel/FormOptions, Task 9). Константы именованные (ObjectRootSegment,
KeyNameUnsafeCharacters, KeyNameReplacement, DefaultAttachmentName).
- `src/core/tests/Deal.Tests.Unit/FakeFileStorage.cs` — in-memory IFileStorage (Put с позиции 0 — Ruling T6;
Get→null на отсутствии; Delete идемпотентный; StoredObjectKeys/DeletedKeys/ContentOf для проверок).
- `src/core/tests/Deal.Tests.Unit/ProjectFilesServiceTests.cs` — 14 тестов: add по mime (image/«Изображение») и
по расширению без mime (document/«Документ»); мета/objectKey-форма/Size; порядок двух файлов; add на
несуществующей карточке → null и объект не пишется; пустое имя → «file»; санитизация имени в objectKey при
сохранении raw-имени в мете; поток с ненулевой позицией → пишется всё содержимое (Ruling T6); GetEntry
(мета / null карточки / null записи); Remove (объект+мета, чужой объект цел, bump UpdatedAt / unknown-id
no-op без delete / пустой objectKey — skip delete / 404 карточки).
### Изменён
- `Application/ProjectsModuleRegistrar.cs``AddScoped<ProjectFilesService>()` (каркас-комментарий это
предписывал: «ProjectFilesService (Task 7)»).
- Ruling T6 (выравнивание адаптеров, указано в контексте задачи):
- `I/Integrations/Storage/LocalFileStorage.cs` — PutAsync сбрасывает перемотаемый поток в 0 перед записью
(выравнивание с Minio-адаптером; XML-doc обновлён).
- `I/Integrations/Storage/MinioFileStorage.cs` — GetAsync: при ошибке ≠ ObjectNotFoundException буфер
Dispose + rethrow (не течёт частично заполненный MemoryStream); NotFound по-прежнему → null.
- `Deal.Contracts/Integrations/IFileStorage.cs` — XML-doc PutAsync: содержимое читается с позиции 0 (Ruling T6).
- `LocalFileStorageTests.cs` — +1 тест «Put потока с ненулевой позиции пишет всё содержимое» (10 тестов).
## Проверка
1. `dotnet build Deal.sln` (src/core) — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. `dotnet test Deal.sln`**599/599 PASS** (таргетно ProjectFilesServiceTests + LocalFileStorageTests +
FileKindDetectorTests — 32/32). MarkerTests PASS.
## Решения и замечания
- **Имена методов — по плану Task 7** (AddAsync/GetEntryAsync/RemoveAsync), а не по формулировке брифа
(Attach/Download/Delete): Task 9 (L393401) вызывает именно `AddAsync` и `GetEntryAsync`; download-поток
резолвит эндпоинт (410/404-различение требует objectKey в эндпоинте), сервис отдаёт мету (entry). «Download
мета+поток» из брифа покрыто на уровне связки GetEntryAsync (мета) + IFileStorage.GetAsync (поток), что
проверит curl-приёмка Task 9; FileMeta (Task 6) задействуется там же.
- **400/лимиты**: 1:1 с прототипом — в files.py/object_store.py/routes 400 на «пустое имя/нет файла/размер» и
лимитов размера/количества НЕТ; вопрос брифа «лимит размера?» снят сверкой (см. class-doc). Пустое имя —
дефолт «file», как прототип. Null-возврат = 404 «Карточка не найдена» (текст у эндпоинта Task 9).
- Код-стайл: 1 тип = 1 файл; XML-doc на публичных контрактах; русские комментарии; без регионов; именованные
константы; сортировка/порядок не менялись.
@@ -0,0 +1,354 @@
#!/usr/bin/env sh
# Task 8 curl-приёмка /api/projects на :5080 (план Task 8 L383388, Rulings 5/6/7/9/11;
# projects_routes.py L15150; api-map §3.5 L155168). Сценарий: сброс kanban/проектных таблиц →
# запуск Deal.Api с DEAL_DEMO=1 (Development) → 401 без куки → login admin/admin → GET /api/projects
# пусто {items:[]} → POST /projects {title:''} (local/planned/createdLocal) → POST полной карточки →
# GET/{id} → PATCH (title/stack/budget, рост updatedAt; budget:null — очистка) → GET?stage= → move work
# (история + reminder null) → move невалидной стадии 400 → comments (пустой 400 / текст {comments}) →
# links (https-префикс, name=url по умолчанию, http остаётся) → DELETE links/{id} → 404 карточки на
# GET/PATCH/move/comment/link → take несуществующего лида 404 «Лид не найден» → demo-лид (simulate) →
# take {leadId} → лид col=taken (psql, GET /leads?col=inbox его не видит), проектная local=false с
# комментарием «Взял в работу из лида.» → move rejected → clear-rejected {ok,cleared:1} (другие целы) →
# logout → 401. Очистка созданных строк после приёмки.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task8-jar.txt"
OUT="/tmp/task8-out.txt"
LOG="/tmp/task8-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
PRJ_A=""
PRJ_B=""
PRJ_C=""
LEAD_ID=""
LINK_A=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
# Первый id (pr_/l_) из JSON-тела ответа: тело — первая строка $OUT (вторая — служебный [HTTP:...]).
extract_id() {
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\|l_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
# updatedAt (epoch-ms) из тела ответа.
extract_updated_at() {
sed -n '1{s/.*"updatedAt":\([0-9][0-9]*\).*/\1/p}' "$OUT"
}
stop_app() {
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка Api и очистка созданных строк =="
stop_app "$APP_PID"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\" WHERE \"CardId\" = '$LEAD_ID';" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" IN ('$PRJ_A','$PRJ_B','$PRJ_C');" >/dev/null 2>&1
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка kanban/проектных таблиц дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\") + (SELECT count(*) FROM \"$SCHEMA\".\"CardMoves\");")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] kanban/проектные таблицы пусты"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. 401 без сессии на /api/projects* =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST -H "Content-Type: application/json" -d '{"title":"x"}' "$BASE_URL/api/projects" > "$OUT"
check "POST /api/projects без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST -H "Content-Type: application/json" -d '{"stage":"work"}' "$BASE_URL/api/projects/pr_x/move" > "$OUT"
check "POST /move без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 3. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 4. GET /api/projects пуст (заглушка снята: реальный список) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects → 200 {items:[]}" '[HTTP:200]' '{"items":[]}'
echo
echo "== 5. POST /api/projects {title:''} — локальная карточка =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
check "история createdLocal" '"type":"createdLocal"'
PRJ_A=$(extract_id)
echo " -> PRJ_A: $PRJ_A"
if [ -z "$PRJ_A" ]; then exit 1; fi
echo
echo "== 6. POST /api/projects — полная карточка (title/stack/budget/contact/tzText/stage) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"title":"Client React","summary":"Corp portal","stack":["React","Node"],"budget":{"from":1000,"to":2500,"cur":"USD"},"contact":"@client","tzText":"MVP in 3 months","stage":"reply"}' \
"$BASE_URL/api/projects" > "$OUT"
check "полная карточка 200: поля и стадия reply" '[HTTP:200]' '"local":true' '"stage":"reply"' '"contact":"@client"'
check "стек и бюджет сохранены" '"stack":["React","Node"]' '"budget":{"from":1000,"to":2500,"cur":"USD"}'
PRJ_B=$(extract_id)
echo " -> PRJ_B: $PRJ_B"
if [ -z "$PRJ_B" ]; then exit 1; fi
T_CREATE=$(extract_updated_at)
echo
echo "== 7. GET /api/projects/{id} и фильтр ?stage= =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
check "GET /{id} → карточка 200" '[HTTP:200]' "\"id\":\"$PRJ_B\"" '"title":"Client React"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects?stage=reply" > "$OUT"
check "GET ?stage=reply → только PRJ_B" '[HTTP:200]' "\"id\":\"$PRJ_B\""
if grep -qF -- "\"id\":\"$PRJ_A\"" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в ?stage=reply попала карточка не той стадии (PRJ_A)"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] фильтр стадии исключил planned-карточку"
fi
echo
echo "== 8. PATCH — title/stack/budget, рост updatedAt; budget:null — очистка =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d '{"title":"Client React v2","stack":["Vue","Go"],"budget":{"from":2000,"to":4000,"cur":"EUR"},"tzText":"New spec"}' \
"$BASE_URL/api/projects/$PRJ_B" > "$OUT"
check "PATCH 200: изменения на месте" '[HTTP:200]' '"title":"Client React v2"' '"stack":["Vue","Go"]' '"budget":{"from":2000,"to":4000,"cur":"EUR"}'
T_PATCH=$(extract_updated_at)
if [ "$T_PATCH" -gt "$T_CREATE" ] 2>/dev/null; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] updatedAt вырос ($T_CREATE$T_PATCH)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] updatedAt не вырос: $T_CREATE$T_PATCH"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d '{"budget":null}' "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
check "PATCH budget:null → бюджет очищен" '[HTTP:200]' '"budget":null'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d '{"stack":null}' "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
check "PATCH stack:null → стек пуст" '[HTTP:200]' '"stack":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d 'not-json' "$BASE_URL/api/projects/$PRJ_B" > "$OUT"
check "PATCH не-JSON → 400 {detail}" '[HTTP:400]' 'Тело запроса должно быть JSON-объектом'
echo
echo "== 9. POST /{id}/move: work (история + reminder null); невалидная стадия 400 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"stage":"work"}' "$BASE_URL/api/projects/$PRJ_B/move" > "$OUT"
check "move work 200" '[HTTP:200]' '"stage":"work"' '"reminder":null'
H_MOVE=$(grep -o '"id":"h_' "$OUT" | wc -l | tr -d ' ')
if [ "$H_MOVE" = "2" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] история движения пополнена (2 записи: createdLocal + move)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] история не пополнена: записей h_ = $H_MOVE"
fi
check "запись move в истории" '"stage":"work"}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"stage":"bogus"}' "$BASE_URL/api/projects/$PRJ_B/move" > "$OUT"
check "move невалидной стадии → 400 «Неизвестная стадия»" '[HTTP:400]' 'Неизвестная стадия'
echo
echo "== 10. POST /{id}/comments: пустой 400, текст → {comments:[...]} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"text":" "}' "$BASE_URL/api/projects/$PRJ_B/comments" > "$OUT"
check "пустой комментарий → 400 «Пустой комментарий»" '[HTTP:400]' 'Пустой комментарий'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"text":"first-comment"}' "$BASE_URL/api/projects/$PRJ_B/comments" > "$OUT"
check "комментарий → {comments:[...]}" '[HTTP:200]' '"comments":[{"id":"cm_' '"by":"Вы"' '"text":"first-comment"' '"time":"только что"'
echo
echo "== 11. POST /{id}/links: https-префикс, name=url по умолчанию, http остаётся =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"url":"example.com"}' "$BASE_URL/api/projects/$PRJ_B/links" > "$OUT"
check "ссылка без схемы → https://, name = url" '[HTTP:200]' '"links":[{"id":"pl_' '"name":"https://example.com"' '"url":"https://example.com"'
LINK_A=$(sed -n '1{s/.*"links":\[{"id":"\(pl_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT")
echo " -> LINK_A: $LINK_A"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"name":"Site","url":"http://x.ru"}' "$BASE_URL/api/projects/$PRJ_B/links" > "$OUT"
check "вторая ссылка: http:// сохранён" '[HTTP:200]' '"name":"Site"' '"url":"http://x.ru"'
echo
echo "== 12. DELETE /{id}/links/{linkId} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ_B/links/$LINK_A" > "$OUT"
check "ссылка удалена → карточка без неё" '[HTTP:200]' "\"id\":\"$PRJ_B\""
if grep -qF -- "$LINK_A" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] удалённая ссылка осталась в карточке"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] удалённой ссылки в ответе нет"
fi
echo
echo "== 13. 404 «Карточка не найдена» на несуществующей карточке =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/pr_dead00000000" > "$OUT"
check "GET /{id} несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH -H "Content-Type: application/json" \
-d '{"title":"x"}' "$BASE_URL/api/projects/pr_dead00000000" > "$OUT"
check "PATCH несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"stage":"work"}' "$BASE_URL/api/projects/pr_dead00000000/move" > "$OUT"
check "move несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"text":"some-text"}' "$BASE_URL/api/projects/pr_dead00000000/comments" > "$OUT"
check "comment несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"url":"example.com"}' "$BASE_URL/api/projects/pr_dead00000000/links" > "$OUT"
check "link несуществующей → 404" '[HTTP:404]' 'Карточка не найдена'
echo
echo "== 14. POST /take несуществующего лида → 404 «Лид не найден» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"leadId":"l_nonexistent0000"}' "$BASE_URL/api/projects/take" > "$OUT"
check "take → 404 «Лид не найден»" '[HTTP:404]' 'Лид не найден'
echo
echo "== 15. Demo-лид (simulate) → take {leadId}: лид уходит в taken, проектная создана =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead 200 (demo)" '[HTTP:200]' '"id":"l_' '"col":"inbox"'
LEAD_ID=$(extract_id)
echo " -> LEAD_ID: $LEAD_ID"
if [ -z "$LEAD_ID" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
check "лид виден в inbox до take" '[HTTP:200]' "\"id\":\"$LEAD_ID\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
check "take лида 200: карточка из лида" '[HTTP:200]' '"local":false' "\"leadId\":\"$LEAD_ID\"" '"stage":"planned"'
check "комментарий «Взял в работу из лида.»" '"text":"Взял в работу из лида."'
check "история created" '"type":"created"'
PRJ_C=$(extract_id)
echo " -> PRJ_C: $PRJ_C"
if [ -z "$PRJ_C" ]; then exit 1; fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
if grep -qF -- "\"id\":\"$LEAD_ID\"" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] лид остался виден в inbox после take"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] GET /leads?col=inbox больше не видит лида"
fi
LEAD_COL=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$LEAD_ID';")
if [ "$LEAD_COL" = "taken" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: лид col=taken"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: col лида = $LEAD_COL (ожидалось taken)"
fi
PRJ_ROW=$($PSQL_BASE -t -A -c "SELECT \"Stage\" || '|' || \"Local\" || '|' || \"LeadId\" FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ_C';")
if [ "$PRJ_ROW" = "planned|false|$LEAD_ID" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: проектная карточка planned/local=false/leadId"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: строка ProjectCards = $PRJ_ROW"
fi
echo
echo "== 16. Повторный take того же лида — идемпотентность (та же карточка) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d "{\"leadId\":\"$LEAD_ID\"}" "$BASE_URL/api/projects/take" > "$OUT"
check "повторный take → та же карточка" '[HTTP:200]' "\"id\":\"$PRJ_C\"" "\"leadId\":\"$LEAD_ID\""
echo
echo "== 17. rejected → clear-rejected {ok,cleared:1}; остальные карточки целы =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" \
-d '{"stage":"rejected"}' "$BASE_URL/api/projects/$PRJ_C/move" > "$OUT"
check "move PRJ_C в rejected 200" '[HTTP:200]' '"stage":"rejected"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
check "clear-rejected → {ok:true, cleared:1}" '[HTTP:200]' '{"ok":true,"cleared":1}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects: PRJ_A и PRJ_B живы" '[HTTP:200]' "\"id\":\"$PRJ_A\"" "\"id\":\"$PRJ_B\""
if grep -qF -- "\"id\":\"$PRJ_C\"" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] очищенная rejected-карточка осталась в списке"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] rejected-карточка удалена из списка"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/projects/clear-rejected" > "$OUT"
check "clear-rejected на пустой стадии → cleared:0" '[HTTP:200]' '{"ok":true,"cleared":0}'
echo
echo "== 18. logout → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" -gt 0 ]; then
echo " [FAIL] есть упавшие проверки — хвост лога Api:"
tail -n 30 "$LOG"
exit 1
fi
@@ -0,0 +1,78 @@
# Task 8 — Эндпоинты /api/projects: карточки, стадии, комментарии, ссылки; замена boot-заглушки; curl-приёмка — отчёт
Статус: **DONE** (build 0/0; 599/599 PASS; curl-приёмка :5080 — **50/50 PASS**). План:
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 8 (L360388), Rulings 5/6/7/9/11;
источники `backend/app/routers/projects_routes.py` L15150, `projects.py` L58231, api-map §3.5 L155168,
§4.3 L282300; фронт store.js L18962131 (createLocalProject/clearRejectedProjects/startProject/
moveProject/patchProject/addProjectComment/addProjectLink/removeProjectLink).
## Файлы
### Создан
- `Deal.Api/Endpoints/ProjectsEndpoints.cs``MapProjectsEndpoints()`: 10 эндпоинтов (файл- и
reminder-эндпоинты — задачи 9/10, Ruling 9). Контракт 1:1 с роутером: GET `/api/projects?stage=`
`{items:[…]}` (обёртка списка); GET `/{cardId}` → карточка | 404 «Карточка не найдена»; POST `""`
(CreateLocalProjectRequest) → карточка (Ruling 6); POST `/take` `{leadId}` → карточка | 404 «Лид не
найден» (Ruling 5); POST `/clear-rejected``{ok:true, cleared}`; PATCH `/{cardId}` — presence-aware
тело `Dictionary<string, JsonElement>` (сигнатура ProjectsService.PatchAsync из Task 4; чтение как в
PATCH /api/settings SettingsEndpoints, не-JSON-объект → 400) → карточка | 404; POST `/{cardId}/move`
`{stage}` → карточка | 400 «Неизвестная стадия» | 404; POST `/{cardId}/comments` `{text}`
`{comments:[…]}` | 400 «Пустой комментарий» | 404; POST `/{cardId}/links` `{name?,url}` → карточка |
400 «Пустая ссылка» | 404; DELETE `/{cardId}/links/{linkId}` → карточка. Статические `/take` +
`/clear-rejected` зарегистрированы до `/{cardId}`; вложенные — за `/{cardId}` (Ruling 9, api-map L1924).
Сессия 401-гейтом (HasUser → EndpointResults.Unauthorized), сервис из RequestServices ПОСЛЕ гейта
(эталон LeadsEndpoints/SettingsEndpoints — scoped-зависимости на tenant-контексте). 400-строки —
константы ProjectsService (UnknownStageDetail/EmptyCommentDetail/EmptyLinkDetail) и свои
CardNotFoundDetail/LeadNotFoundDetail/InvalidBodyDetail.
- `Deal.Api/Endpoints/RequestModels/CreateLocalProjectRequest.cs` (title/summary/stack/budget/contact/
tzText/stage — 1:1 CreateBody routes L2028; дефолты pydantic), `TakeLeadRequest.cs` (`{leadId}`),
`MoveStageRequest.cs` (`{stage}`), `ProjectCommentRequest.cs` (`{text}`), `ProjectLinkRequest.cs`
(`{name="",url}`).
### Изменён
- `Deal.Api/Endpoints/BootStubEndpoints.cs` — GET /api/projects-заглушка и константа ProjectsPath удалены,
остаётся GET /api/tg/status (этап 6); комментарий класса обновлён (Ruling 9).
- `Deal.Api/Program.cs``builder.Services.AddProjectsModule()` (регистрация была отложена до этого шага,
Ruling 2) + `app.MapProjectsEndpoints()`; `Deal.Api/Deal.Api.csproj` — ProjectReference на
`Deal.Modules.Projects`.
- `.superpowers/sdd/deal-stage5-projects/task-8-curl-acceptance.sh` — приёмочный сценарий :5080 (ниже).
## Решения и замечания
- **ProjectPatchRequest.cs НЕ создан** (отклонение от списка файлов Task 8 L374): PATCH-тело обязано быть
presence-aware `Dictionary<string, JsonElement>` — таков контракт сервиса (Task 4: «PATCH-тело как
Dictionary» в брифе), типизированный record с all-optional полями неотличим от «поля нет» (бюджет
чистится явным `budget:null`, Ruling 11). Прецедент — PATCH /api/settings (SettingsEndpoints) без
request-модели; создавать неиспользуемый тип против конвенции «1 тип = 1 файл, без dead-кода» не стал.
- **take-тело сверено: `{leadId}`** (TakeBody routes L1617) — как в брифе. Отсутствующий leadId/пустой →
строка не находится → 404 «Лид не найден» (pydantic-422 сводим к прототипному 404, прецедент null-тел
LeadsEndpoints). Аналогично `{text}`/`{stage}`/`{url}` null трактуются валидациями сервисов
(400 «Пустой комментарий»/«Неизвестная стадия»/«Пустая ссылка»).
- Порядок проверок 1:1 с роутером: comment — пустой текст 400 раньше 404 карточки; link — 404 карточки
раньше 400 пустого url (routes L124128, L133143; сервисы Tasks 4/5 это уже зафиксировали).
- Curl-сценарий на Windows: кириллица в **телах запросов** curl искажает (argv → локальная кодовая
страница, не UTF-8) — в `-d` используются ASCII-значения, ответы сервера проверяются кириллическими
подстроками (это и есть источник UTF-8 с сервера). Ограничение скрипта, не API.
## Проверка
1. `scripts/build.sh` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. `scripts/test.sh`**599/599 PASS** (новых unit-тестов Task 8 не требует: endpoint-слои — curl;
MarkerTests остаются).
3. Curl-приёмка :5080 (`task-8-curl-acceptance.sh``task-8-curl-acceptance.log`) — **PASS=50 FAIL=0**:
сброс таблиц → запуск Deal.Api (Development, DEAL_DEMO=1) → 401 без куки (GET/POST/move) → login
admin/admin → GET /projects `{items:[]}` (заглушка снята) → POST `{title:''}` (local=true, planned,
createdLocal-история) → POST полной карточки (stage reply, стек/бюджет) → GET/{id} + `?stage=`-фильтр →
PATCH (title/stack/budget; updatedAt вырос; `budget:null` → бюджет null; `stack:null` → []; не-JSON →
400) → move work (история +1, reminder null) / move bogus → 400 «Неизвестная стадия» → comments
(пустой 400 «Пустой комментарий»; текст → `{comments:[…]}` cm_/«Вы»/«только что») → links (без схемы →
https://, name=url; http:// сохранён) → DELETE links/{id} (карточка без ссылки) → 404 «Карточка не
найдена» на GET/PATCH/move/comment/link несуществующей → take несуществующего лида → 404 «Лид не
найден» → simulate-lead → take {leadId} (карточка local=false, leadId, planned, «Взял в работу из
лида.», history created; psql Cards col=taken; GET /leads?col=inbox лида не видит; ProjectCards
planned|false|leadId) → повторный take — та же карточка → move rejected → clear-rejected `{cleared:1}`
(PRJ_A/PRJ_B целы, PRJ_C удалена) → повторный clear-rejected `{cleared:0}` → logout → 401. После
приёмки: строки очищены (0|0), в логе Api исключений нет, порт :5080 свободен.
## Отчёт
`.superpowers/sdd/deal-stage5-projects/task-8-report.md`; ledger progress.md обновлён (Task 8 complete).
@@ -0,0 +1,358 @@
#!/usr/bin/env sh
# Task 9 curl-приёмка файл-эндпоинтов /api/projects/{cardId}/files* на :5080 (план Task 9 L390-409,
# Rulings 4/11 + Ruling T6; projects_routes.py L155-186; files.py L57-94; api-map L7-19, L168-172).
# Сценарий: очистка ProjectCards/вложений -> запуск Deal.Api (Development, DEAL_DEMO=1, LocalFileStorage)
# -> 401 без куки (upload/download/delete) -> login admin/admin -> локальная карточка -> upload 2 файлов
# (tz.pdf document/Документ, photo.png image/Изображение) -> GET карточки (files с kind/label/size) ->
# download (200, attachment, octet-stream, Content-Length, байты совпадают) -> download чужого/нет записи
# 404 -> upload на несуществующую карточку 404 -> upload не-multipart 400 -> DELETE файла {ok:true},
# карточка без файла, объект удалён из data/attachments, download удалённого 404 -> запись с пустым
# objectKey -> 410 (через psql) -> объект удалён напрямую из хранилища -> download 404 «Файл не найден
# в MinIO» -> logout -> 401. Очистка созданных строк/вложений после приёмки.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
# Рабочий каталог приёмки — Windows-TEMP в Windows-форме (native curl.exe не понимает bash-путь /tmp:
# аргументы с '=' (files=@путь) не конвертируются MSYS-рантаймом). Все файлы (мультипарт-источники, jar,
# body, headers) живут здесь — bash и curl видят один и тот же путь.
TMPB=$(cygpath -m /tmp)/task9
SRC="$TMPB/files"
JAR="$TMPB/jar.txt"
OUT="$TMPB/out.txt"
HDR="$TMPB/hdr.txt"
HDRN="$TMPB/hdrn.txt"
DL="$TMPB/dl.bin"
LOG="$TMPB/api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
ATTACH="$API_DIR/data/attachments"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
PRJ=""
FID_PDF=""
FID_PNG=""
FID_410=""
KEY_PDF=""
SZ_PDF=""
SZ_PNG=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
# Проверка по заголовкам ответа (нормализованы в $HDRN: lowercase, без \r).
header_check() {
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$HDRN"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено в заголовках: $*"
echo "--- заголовки:"
cat "$HDRN"
fi
}
# Первый id (pr_) из JSON-тела ответа (тело — первая строка $OUT, вторая — служебный [HTTP:...]).
extract_id() {
sed -n '1{s/.*"id":"\(pr_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
# n-й файловый id (pf_) из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
file_id_at() {
grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$1" | sed -n "${2}s/.*\"id\":\"\(pf_[0-9a-f][0-9a-f]*\)\"/\1/p"
}
# n-й objectKey из JSON-тела ответа ($1 — файл ответа, $2 — номер вхождения).
object_key_at() {
grep -o '"objectKey":"[^"]*"' "$1" | sed -n "${2}s/.*\"objectKey\":\"\([^\"]*\)\"/\1/p"
}
stop_app() {
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка Api и очистка созданных строк/вложений =="
stop_app "$APP_PID"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\" WHERE \"Id\" = '$PRJ';" >/dev/null 2>&1
rm -rf "$ATTACH/projects/$PRJ" 2>/dev/null
rm -rf "$TMPB"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$HDR" "$HDRN" "$DL" "$LOG"
mkdir -p "$SRC"
printf '%s' '%PDF-1.4 Task9 tz document bytes 1234567890' > "$SRC/tz.pdf"
printf '%s' 'Task9 photo bytes png 0987654321 xyz' > "$SRC/photo.png"
SZ_PDF=$(wc -c < "$SRC/tz.pdf")
SZ_PNG=$(wc -c < "$SRC/photo.png")
echo "== 0. Очистка ProjectCards дефолтного тенанта и вложений (повторяемость) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"ProjectCards\";" >/dev/null
rm -rf "$ATTACH/projects" 2>/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"ProjectCards\";")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] ProjectCards пусты"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development, LocalFileStorage) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
grep -q 'LocalFileStorage' "$LOG"
if [ $? = 0 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] стартовый лог: LocalFileStorage (приёмка в local-режиме)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] стартовый лог не содержит LocalFileStorage:"
head -n 3 "$LOG"
fi
echo " health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. 401 без сессии на файл-эндпоинтах =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects/pr_x/files/pf_x/download" > "$OUT"
check "GET download без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X DELETE "$BASE_URL/api/projects/pr_x/files/pf_x" > "$OUT"
check "DELETE файла без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST -F "files=@$SRC/tz.pdf;type=application/pdf" "$BASE_URL/api/projects/pr_x/files" > "$OUT"
check "POST files без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 3. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' "$BASE_URL/api/auth/login" > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 4. Локальная карточка POST /api/projects {title:''} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{"title":""}' "$BASE_URL/api/projects" > "$OUT"
check "создана карточка 200" '[HTTP:200]' '"local":true' '"stage":"planned"'
PRJ=$(extract_id)
echo " -> PRJ: $PRJ"
if [ -z "$PRJ" ]; then exit 1; fi
echo
echo "== 5. Upload 2 файлов: tz.pdf (pdf) + photo.png (png) → {items:[2]} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
-F "files=@$SRC/tz.pdf;type=application/pdf;filename=tz.pdf" \
-F "files=@$SRC/photo.png;type=image/png;filename=photo.png" \
"$BASE_URL/api/projects/$PRJ/files" > "$OUT"
check "upload 200 {items:[2]}" '[HTTP:200]' '"items":[' '"id":"pf_'
check "tz.pdf → document/Документ" '"name":"tz.pdf"' '"kind":"document"' '"label":"Документ"'
check "photo.png → image/Изображение" '"name":"photo.png"' '"kind":"image"' '"label":"Изображение"'
check "size записей = размеры файлов" "\"size\":$SZ_PDF" "\"size\":$SZ_PNG"
FID_PDF=$(file_id_at "$OUT" 1)
FID_PNG=$(file_id_at "$OUT" 2)
KEY_PDF=$(object_key_at "$OUT" 1)
echo " -> FID_PDF: $FID_PDF, FID_PNG: $FID_PNG"
echo " -> KEY_PDF: $KEY_PDF"
if [ -z "$FID_PDF" ] || [ -z "$FID_PNG" ] || [ -z "$KEY_PDF" ]; then exit 1; fi
echo
echo "== 6. GET /api/projects/{id}: files со счётчиком, объекты на диске data/attachments =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "карточка с files: оба файла в массиве" '[HTTP:200]' '"files":[' '"name":"tz.pdf"' '"name":"photo.png"'
FILES_COUNT=$(grep -o '"id":"pf_[0-9a-f][0-9a-f]*"' "$OUT" | wc -l)
if [ "$FILES_COUNT" = "2" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] files содержит 2 записи"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] files содержит записей: $FILES_COUNT"
fi
OBJ_COUNT=$(ls "$ATTACH/projects/$PRJ" 2>/dev/null | wc -l)
if [ "$OBJ_COUNT" = "2" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] 2 объекта в data/attachments/projects/$PRJ"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] объектов на диске: $OBJ_COUNT (ожидалось 2)"
fi
echo
echo "== 7. Download tz.pdf: 200, attachment, octet-stream, Content-Length, байты совпадают =="
curl -s -D "$HDR" -o "$DL" -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PDF/download" > "$OUT"
check "download 200" '[HTTP:200]'
tr -d '\r' < "$HDR" | tr '[:upper:]' '[:lower:]' > "$HDRN"
header_check "Content-Disposition attachment + имя" 'content-disposition:' 'attachment' 'tz.pdf'
header_check "Content-Type octet-stream (local-режим)" 'content-type: application/octet-stream'
header_check "Content-Length = размер файла" "content-length: $SZ_PDF"
if cmp -s "$SRC/tz.pdf" "$DL"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] байты download совпадают с загруженным tz.pdf"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] байты download НЕ совпадают с tz.pdf"
fi
echo
echo "== 8. Download photo.png: 200 + байты совпадают =="
curl -s -D "$HDR" -o "$DL" -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PNG/download" > "$OUT"
check "download 200" '[HTTP:200]'
tr -d '\r' < "$HDR" | tr '[:upper:]' '[:lower:]' > "$HDRN"
header_check "photo.png attachment" 'content-disposition:' 'attachment' 'photo.png'
header_check "Content-Length png" "content-length: $SZ_PNG"
if cmp -s "$SRC/photo.png" "$DL"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] байты download совпадают с загруженным photo.png"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] байты download НЕ совпадают с photo.png"
fi
echo
echo "== 9. Download записи, которой нет в метаданных → 404 «Карточка не найдена» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/pf_dead00000000/download" > "$OUT"
check "download pf_dead → 404" '[HTTP:404]' 'Карточка не найдена'
echo
echo "== 10. Upload на несуществующую карточку → 404; не-multipart → 400 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
-F "files=@$SRC/tz.pdf;type=application/pdf" "$BASE_URL/api/projects/pr_dead00000000/files" > "$OUT"
check "upload на pr_dead → 404 «Карточка не найдена»" '[HTTP:404]' 'Карточка не найдена'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST -H "Content-Type: application/json" -d '{}' "$BASE_URL/api/projects/$PRJ/files" > "$OUT"
check "upload JSON → 400 «Ожидается multipart/form-data»" '[HTTP:400]' 'Ожидается multipart/form-data'
echo
echo "== 11. DELETE photo.png: {ok:true}; карточка без файла; объект удалён с диска; download → 404 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/files/$FID_PNG" > "$OUT"
check "DELETE файла → 200 {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "карточка без photo.png" '"name":"tz.pdf"'
if grep -qF -- '"name":"photo.png"' "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] photo.png остался в files карточки"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] photo.png удалён из files карточки"
fi
OBJ_COUNT=$(ls "$ATTACH/projects/$PRJ" 2>/dev/null | wc -l)
if [ "$OBJ_COUNT" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] объект photo.png удалён из data/attachments (остался tz.pdf)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] объектов на диске после DELETE: $OBJ_COUNT (ожидалось 1)"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PNG/download" > "$OUT"
check "download удалённого → 404 «Карточка не найдена»" '[HTTP:404]' 'Карточка не найдена'
echo
echo "== 12. Запись с пустым objectKey (psql) → download 410 =="
$PSQL_BASE -c "UPDATE \"$SCHEMA\".\"ProjectCards\" SET \"FilesJson\" = replace(\"FilesJson\", '\"objectKey\":\"$KEY_PDF\"', '\"objectKey\":\"\"') WHERE \"Id\" = '$PRJ';" >/dev/null
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PDF/download" > "$OUT"
check "download без objectKey → 410 «Файл не сохранён в объектном хранилище»" '[HTTP:410]' 'Файл не сохранён в объектном хранилище'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/files/$FID_PDF" > "$OUT"
check "DELETE записи без objectKey → {ok:true} (delete объекта пропущен)" '[HTTP:200]' '"ok":true'
echo
echo "== 13. Объект удалён напрямую из хранилища при живой мете → download 404 «Файл не найден в MinIO» =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST \
-F "files=@$SRC/tz.pdf;type=application/pdf;filename=tz.pdf" \
"$BASE_URL/api/projects/$PRJ/files" > "$OUT"
check "повторный upload tz.pdf → {items:[1]}" '[HTTP:200]' '"name":"tz.pdf"'
FID_PDF=$(file_id_at "$OUT" 1)
KEY_PDF=$(object_key_at "$OUT" 1)
rm -f "$ATTACH/$KEY_PDF"
if [ ! -f "$ATTACH/$KEY_PDF" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] объект удалён из data/attachments напрямую"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] объект не удалился: $ATTACH/$KEY_PDF"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ/files/$FID_PDF/download" > "$OUT"
check "download отсутствующего объекта → 404 «Файл не найден в MinIO»" '[HTTP:404]' 'Файл не найден в MinIO'
echo
echo "== 14. DELETE файла с удалённым объектом → {ok:true}; карточка без files =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/projects/$PRJ/files/$FID_PDF" > "$OUT"
check "DELETE → {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects/$PRJ" > "$OUT"
check "карточка с files:[]" '"files":[]'
echo
echo "== 15. logout → 401 на download =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects/$PRJ/files/pf_x/download" > "$OUT"
check "download после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== Проверка лога Api: исключений нет =="
if grep -qE 'Exception|\[ERR\]|Unhandled' "$LOG"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе Api есть исключения:"
grep -E 'Exception|\[ERR\]|Unhandled' "$LOG" | head -n 5
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] лог Api чист (без исключений)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" -gt 0 ]; then
echo " [FAIL] есть упавшие проверки — хвост лога Api:"
tail -n 25 "$LOG"
exit 1
fi
echo " [PASS] файл-эндпоинты приняты"
exit 0
@@ -0,0 +1,99 @@
# Task 9 — Файл-эндпоинты /api/projects/{cardId}/files* — upload/download/delete + curl-приёмка — отчёт
Статус: **DONE** (build 0/0; 602/602 PASS — 599 этапов 18 + 3 новых StatAsync-теста LocalFileStorage;
curl-приёмка :5080 — **41/41 PASS**, локальный режим LocalFileStorage). План:
`docs/superpowers/plans/2026-09-05-deal-stage5-projects.md` Task 9 (L390409), Rulings 4/11 + Ruling T6;
источники `backend/app/routers/projects_routes.py` L155186, `files.py` L5794, `object_store.py` L61108,
api-map L719/L168172, §4.3 L283301; фронт store.js L20312053 (addProjectFiles/removeProjectFile),
ProjectDrawer.vue (прямая ссылка …/files/{id}/download).
## Файлы
### Изменены (контракт/адаптеры — закрытие Ruling T6)
- `Deal.Contracts/Integrations/IFileStorage.cs` — новый метод порта `StatAsync(objectKey, ct) → FileMeta?`
(MinIO StatObject / FileInfo локального файла; null — объекта нет). Ruling T6 (progress.md L19: «FileMeta
задействуется в T9 — download: Content-Length/Type через StatObject/FileInfo») закрыт: дескриптор больше не
«мёртвый тип», у download-эндпоинта есть источник Content-Length/Content-Type и отдельная проверка
«объекта нет» до открытия потока. XML-doc порта/дескриптора обновлены.
- `Deal.Contracts/Integrations/Models/FileMeta.cs` — XML-doc: у типа появился потребитель (StatAsync →
download Task 9); описан пустой ContentType для Local-режима.
- `Deal.Infrastructure/Integrations/Storage/LocalFileStorage.cs``StatAsync`: FileInfo (размер; объект
отсутствует → null); ContentType пуст — локально MIME не хранится (как прототип: пишутся только байты).
- `Deal.Infrastructure/Integrations/Storage/MinioFileStorage.cs``StatAsync`: `StatObjectAsync`
FileMeta(размер + contentType, сохранённый при put); `ObjectNotFoundException` → null (как GetAsync);
иные сбои уходят вызывающему (эндпоинт мапит их в 404 «Файл не найден в MinIO»).
- `Deal.Api/Http/EndpointResults.cs` — добавлен `Gone(detail)` (410, формат {detail}; api-map §1: код 410 в
списке ошибок прототипа).
### Изменён (эндпоинты)
- `Deal.Api/Endpoints/ProjectsEndpoints.cs` — 3 файл-маршрута (13 эндпоинтов группы; порядок вложенных за
/{cardId}, Ruling 9):
- **POST /{cardId}/files** — multipart/form-data, поле `files` (routes L155162): 401-гейт; карточка
проверяется ДО чтения формы/записи объектов (эталон _card_or_404 L156; AddAsync не пишет объект на
отсутствующей карточке — приёмка Task 7); `context.Request.ReadFormAsync`; каждый файл
(имя/ContentType/поток/длина) → `ProjectFilesService.AddAsync`; ответ `{items:[§4.3 файл]}`; 404
«Карточка не найдена»; тело не-multipart → 400 «Ожидается multipart/form-data» (новый не-прототипный
текст — фронт так не шлёт, FastAPI-422 у нас не повторяется; прецедент InvalidBodyDetail Task 8).
Лимит тела multipart — дефолты HTTP-слоя (Kestrel MaxRequestBodySize/FormOptions), свой не вводим
(в прототипе лимитов нет, зона — HTTP-слой, зафиксировано в ProjectFilesService class-doc Task 7).
- **GET /{cardId}/files/{fileId}/download** — поток (routes L165179): entry нет (карточка/запись) → 404
«Карточка не найдена» (Ruling 4: прототип на этом пути KeyError/500 — у нас корректный 404);
objectKey пуст → **410** «Файл не сохранён в объектном хранилище»; `StatAsync` + `GetAsync` (null или
сбой хранилища, кроме отмены → 404 «Файл не найден в MinIO» — фиксированная строка routes L173, любое
исключение get → 404); ответ — `Results.Stream` (сам диспозит поток): Content-Length = FileMeta.Size,
Content-Type = FileMeta.ContentType если непуст (MinIO — MIME из put) иначе `application/octet-stream`
(Local-режим, 1:1 routes L174179), Content-Disposition attachment с именем без кавычек «"»
(safe_name L174) — Ruling T6 закрыт. Хелпер `ToDownloadFileName`.
- **DELETE /{cardId}/files/{fileId}** — `{ok:true}` | 404 карточки (routes L182–186); записи нет в
FilesJson — успех без изменений (эталон RemoveLinkAsync/RemoveAsync Task 7); objectKey пуст — delete
объекта пропускается (remove_file L92).
### Создан/изменён (тесты и приёмка)
- `tests/Deal.Tests.Unit/FakeFileStorage.cs` — реализован `StatAsync` (семантика Minio-адаптера: размер +
MIME как при put; null — объекта нет) — интерфейс порта расширен, фейк обязан реализовать.
- `tests/Deal.Tests.Unit/LocalFileStorageTests.cs` — +3 теста Stat: после put → размер и пустой
ContentType; отсутствующий объект → null; «..»-обход → ArgumentException (как Get/Delete).
- `.superpowers/sdd/deal-stage5-projects/task-9-curl-acceptance.sh` (+ лог `task-9-curl-acceptance.log`) —
приёмочный сценарий :5080 (ниже).
## Решения и замечания
- **Ruling T6 закрыт расширением порта**: FileMeta (Key/Size/ContentType) было не у чего «задействовать» —
порт не умел отдавать мету объекта. Добавлен `StatAsync` (MinIO StatObject / Local FileInfo); download
берёт из дескриптора Content-Length и Content-Type. Отклонений от Ruling 4 нет: локальный режим MIME не
хранит → пустой ContentType дескриптора → `application/octet-stream` (1:1 прототип и api-map §1);
MinIO-режим отдаёт сохранённый при put MIME (attachment остаётся — браузер скачивает, а не открывает).
Content-Disposition формирует `Results.Stream` через `fileDownloadName` (имя без кавычек «"»).
- **410 vs 404 порядка 1:1 с routes**: entry/карточка не найдены → 404 «Карточка не найдена» (не 500,
Ruling 4); objectKey пуст → 410 до обращения к хранилищу; объект отсутствует/хранилище недоступно → 404
«Файл не найден в MinIO» (фиксированная строка прототипа, даже для Local-режима — текст 1:1 с routes
L173, не «переводим»).
- **Upload-ответ — мета, не карточка**: `{items:[§4.3 файл]}` — фронт после upload/delete сам перечитывает
карточку (store.js L20312053, api-map §4.3 L300). Ответ на 2 файла — 2 записи в порядке формы.
- **FileKindDetector на download не нужен**: kind/label есть в метаданных записи, но MIME ответа даёт
хранилище (StatObject) либо фиксированный octet-stream; детектор остаётся зоной upload (Task 7).
- Лимиты размера не вводим (прототип без лимитов; HTTP-слой — дефолты Kestrel 30 МБ/FormOptions).
- Curl-сценарий на Windows: native curl (mingw64) не видит bash-путь `/tmp` в `-F files=@…` (аргумент с
`=` не конвертируется MSYS) и в `-c/-b/-o/-D` — рабочие файлы приёмки положены в Windows-TEMP
(`cygpath -m /tmp`) в Windows-форме. Кириллица в теле запросов curl искажается (как в Task 8) — в
запросах ASCII, серверные ответы проверяются кириллическими подстроками.
## Проверка
1. `scripts/build.sh` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. `scripts/test.sh`**602/602 PASS** (599 + 3 StatAsync LocalFileStorage; таргетно
LocalFileStorageTests/ProjectFilesServiceTests/FileKindDetectorTests — зелёные).
3. Curl-приёмка :5080 (`task-9-curl-acceptance.sh``task-9-curl-acceptance.log`) — **PASS=41 FAIL=0**
(local-режим, LocalFileStorage из стартового лога): очистка → запуск → 401 без куки (download/delete/
upload) → login → локальная карточка → upload tz.pdf+photo.png `{items:[2]}` (document/«Документ»,
image/«Изображение», size=файлов; объекты в data/attachments/projects/{pr}) → GET карточки files:2 →
download обоих: 200, Content-Disposition attachment+имя, Content-Type octet-stream, Content-Length,
байты cmp-совпадают → download pf_dead 404 «Карточка не найдена» → upload на pr_dead 404 → upload JSON
400 «Ожидается multipart/form-data» → DELETE photo.png `{ok:true}` (карточка без файла, объект удалён с
диска, download удалённого 404) → objectKey='' через psql → download **410** «Файл не сохранён в
объектном хранилище» (DELETE ok) → объект удалён напрямую из data/attachments → download 404 «Файл не
найден в MinIO» → DELETE → files:[] → logout → 401. Лог Api без исключений; после приёмки строки
очищены (0), порт :5080 свободен.
## Отчёт
`.superpowers/sdd/deal-stage5-projects/task-9-report.md`; ledger progress.md обновлён (Task 9 complete).