1
План stage5 projects
stepan edited this page 2026-09-13 00:17:00 +03:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Перенесено из репозитория (docs/superpowers/plans/2026-09-05-deal-stage5-projects.md). Актуальная версия — здесь, в вики.

Дейл (Deal) — Этап 5: Projects («Выбранные»): стадии, напоминания, файлы/ссылки, история, ручное создание Implementation Plan

Исторический документ этапа 5. Актуальное состояние — docs/superpowers/STATUS.md и docs/technical/Техническая-документация-Дейл.md.

Goal: Оживить в модульном монолите src/core вкладку «Выбранные» Vue-фронта 1:1-контрактом /api проектного канбана: карточки, взятые «в работу» из дашборда (лид уходит безвозвратно, col='taken') и созданные вручную («локальные»), путь по 9 предзаданным стадиям (planned → … → ready/hold, терминальные finished/rejected), редактирование суммы/стека/контактов/ТЗ, комментарии, ссылки, файлы (тип по MIME/расширению; хранение через порт IFileStorage: локальный диск по умолчанию и MinIO при конфигурации), история движения под спойлером, напоминания стадии «Отложено» (окно настройки — фронт, бэкенд хранит at; фоновая проверка в 30-с цикле; SSE reminder_due + баннер), очистка «Отклонено». К концу этапа ProjectsView полностью обслуживается бэкендом (boot-заглушка GET /api/projects {items:[]} заменяется реальным списком), приёмка — unit/curl/psql; «взятые» в архив/корзину дашборда не попадают, автоархив тика их не касается.

Architecture: новый модуль Deal.Modules.Projects (чистый, без EF/HTTP) — владелец таблицы ProjectCards (миграция TenantProjects в TenantDbContext) и логики «Выбранных»: стадии-константы ProjectStages (1:1 constants.py PIPELINE_STAGES), DTO карточки (§4.3), порт IProjectStore, сервисы ProjectsService (чтение, ручное создание, «взять в работу», правка полей, move+история, clear-rejected, комментарии, ссылки), ProjectFilesService (добавить/удалить файл: детект типа → IFileStorage.Put → метаданные в карточку), ProjectReminderService (set/clear/snooze и фоновая проверка due). Чужие владения модуль не трогает: чтение лида и пометку col='taken' выполняет через публичный порт Kanban (IKanjStore.GetCardAsync + новый MarkTakenAsync, Ruling 5); настройки — порт Settings ISettingsStore (remindersEnabled уже в каталоге ключей, дефолт true). Файлы — внешний порт IFileStorage (Contracts/Integrations) с двумя адаптерами в Infrastructure: LocalFileStorage (корень data/attachments, dev-режим по умолчанию) и MinioFileStorage (MinIO S3-клиент, включается секцией Storage:Minio/DEAL_MINIO_*; бакет deal-files создаётся лениво; сервис minio добавляется в deploy/compose.dev.yml). HTTP — Deal.Api/Endpoints/ProjectsEndpoints.cs (MapProjectsEndpoints); фоновая проверка напоминаний — внутри существующего StorageTickScheduler (30 с, паттерн Kanban-тика по тенантам) и ручного POST /api/admin/tick (AdminTickOrchestrator); SSE reminder_due публикуется только из Api-слоя (Ruling 5 этапа 3); boot-заглушка GET /api/projects удаляется (остаётся /tg/status).

Spec: docs/api/api-map.md §3.5 (L153174), §2 SSE (L3343: reminder_due = {id, title, stage}), правила (L7–24: контент-типы multipart/octet-stream, 410/404, «кривые места» L390400 — п.5 reminder_due, п.6 DELETE-400, п.9 экономия: /projects/reminders НЕ реализуем), §4.3 проектная карточка (L280–300), §4.4 стадии (L302304), §3.2 admin/tick reminders (L103112), §4.6 remindersEnabled (L328, L340); docs/spec/ТЗ.md §4.8 «Выбранные» (L119132); roadmap (этап 5, L69–73); референс-семантика прототипа: backend/app/services/projects.py (целиком: _insert/_row_to_card L31100, create_local_card L103124, take_lead_to_projects L127156, patch_card L159199, add_comment L194199, move_stage L202216, clear_stage L223231, напоминания L236–282), backend/app/routers/projects_routes.py (целиком), backend/app/services/files.py (целиком: KIND_BY_EXT/KIND_LABELS L1328, detect L3145, add_file L5775, get_file_entry L7883, remove_file L8694), backend/app/services/object_store.py (целиком: configured, put/get/remove, локальный fallback L5479), backend/app/services/leads.py (L151156, L526545 — взятые исключены из списков/поиска), backend/app/db.py (L103125 — таблица projects), backend/app/constants.py (L1627 — PIPELINE_STAGES), backend/app/sse.py, backend/app/main.py (L4753 — 30-с цикл с check_reminders), backend/app/routers/dashboard_routes.py (admin_tick L327337); фронт: src/frontend/src/views/ProjectsView.vue (колонки по PIPELINE_STAGES data.js), components/ {ProjectColumn,ProjectCard,ProjectDrawer,HoldReminderDialog,ReminderNotice}.vue, store.js (boot L571593; startProject L19541966; moveProject L19681980; patchProject/addProjectComment/addProjectLink/removeProjectLink L19852027; addProjectFiles/removeProjectFile L20312053; setHoldReminder/clearHoldReminder/snooze/ clearDueReminder L20602146; startRealtime L670674 — reminder_due), api.js (openEvents L6283 — слушает reminder_due); конвенции/образцы планов этапов 1–4 (файлы docs/superpowers/plans/2026-09-05-deal-stage{1,2,3,4}-*.md).

Global Constraints

  • Проект НЕ git; фиксация — отчёты задач task-N-report.md и progress.md в .superpowers/sdd/deal-stage5-projects/.
  • .NET 10 SDK, решение собирается 0 warnings / 0 errors (TreatWarningsAsErrors); dev-Postgres deal-postgres (:5433); curl-приёмка :5080 (scripts/build.sh/scripts/test.sh); NuGet Minio — только в этапе файлов (Task 6).
  • Код-стайл этапов 1–4: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; без регионов; без магических чисел (именованные константы); PascalCase-колонки БД; времена — DateTimeOffset (UTC) в БД, наружу epoch-ms; JSON camelCase; ошибки {"detail"}.
  • Модуль Projects — чистый: без EF и HTTP; зависимости — Deal.Contracts (IFileStorage), Deal.Modules.Settings (порт ISettingsStore), Deal.Modules.Kanban (порт IKanjStore и его read-DTO CardDto/CardBudgetDto/CardCommentDto). Реверс-зависимостей нет: Kanban/Settings/Contracts о Projects не знают; публикации SSE — только из Api (Ruling 5 этапа 3); оркестрация тика — AdminTickOrchestrator/StorageTickScheduler.
  • LeadRadar-контейнеры, backend/, mlservice/, src/frontend/ НЕ трогаем; Vue-фронт не переписывается: формы JSON 1:1 с api-map. Проектные карточки живут до терминальной стадии: автоархив/корзина тика (StorageTickService, таблица Cards) их не касается; единственный hard-delete — ручная очистка стадии «Отклонено».
  • Строки ошибок/тостов/комментариев — фиксированные из прототипа (см. задачи): «Карточка не найдена», «Лид не найден», «Пустой комментарий», «Пустая ссылка», «Неизвестная стадия», «Напоминания об отложенных выключены в настройках», «Удаление проектных карточек отключено» (не используется — DELETE не реализуем), «Файл не найден в MinIO», «Файл не сохранён в объектном хранилище», «Взял в работу из лида.».

Зафиксированные решения (Rulings этапа)

  • Ruling 1 (а) — таблицы tenant-схемы (миграция TenantProjects) и стадии. Новая миграция TenantProjects контекста TenantDbContext (папка I/Migrations/TenantDb, применяется провижинером ко всем схемам). Таблиц ОДНА — ProjectCards (1:1 с таблицей projects db.py L103–125; владелец — модуль Projects). Колонки (PascalCase, JSON-массивы — text-колонками как Cards.StackJson): Id (pr_, PK), Stage (строка), Local (bool), LeadId (nullable, БЕЗ FK — «мягкая» ссылка на Cards.Id, конвенция DedupEntries Ruling 1 этапа 4), Title, Summary (text), StackJson (text), BudgetFrom/BudgetTo (double?), BudgetCur (пусто — бюджета нет), Contact, CommentsJson/LinksJson/FilesJson/HistoryJson/TzText (text), ReminderAt (nullable), ReminderFired (bool), CreatedAt, UpdatedAt (DateTimeOffset). JSON-массивы хранят wire-формы элементов (комментарий {id,by,text,time}; ссылка {id,name,url}; файл {id,name,size,kind,label,objectKey}; история {id,at,type|stage}) — как python хранит готовые dict-ы (projects.py _insert L71100). Индексы: (Stage) (idx_projects_stage L125), (UpdatedAt) DESC (порядок списка), частичный UNIQUE (LeadId) WHERE LeadId IS NOT NULL — «один лид → одна проектная карточка» (страховка гонки take). Комментарии/история НЕ выносятся в отдельные таблицы (внешних читателей нет — YAGNI). Стадии канбана «Выбранных» ПРЕДЗАДАНЫ и не являются сущностями (прототип: константа, не таблица) — проверено по прототипу/фронту: 9 фиксированных стадий §4.4 = planned Запланировано #818cf8, reply Отклик #38bdf8, agree Согласование #a78bfa, work В работе #fbbf24, review Проверка #f97316, ready Готово #4ade80, hold Отложено #94a3b8 (не terminal), finished Выполнено #2bd576 (terminal), rejected Отклонено #ff6b6b (terminal) (constants.py L1727). Пользовательских стадий/досок проектного канбана в прототипе НЕТ.
  • Ruling 2 (б) — миграция/владелец/границы. Владелец схемы — модуль Deal.Modules.Projects (Ruling 1); EF-адаптер ProjectStore — в Deal.Infrastructure; регистрация AddProjectsModule() + AddScoped<IProjectStore, ProjectStore> (в AddDealPersistence). Порт IProjectStore объявлен в модуле (эталон IKanjStore/IPipelineStore); DTO-модели — в P/Application/Models/. Публичный контракт наружу (эндпоинты) — сервисы модуля: ProjectsService, ProjectFilesService, ProjectReminderService. csproj модуля: ProjectReference на Deal.Modules.Settings, Deal.Modules.Kanban, Deal.Contracts. HTTP — A/Endpoints/ProjectsEndpoints.cs, Program.csAddProjectsModule() + MapProjectsEndpoints() + AddDealFileStorage(...) (Task 6/8); Api.csproj — ссылка на модуль.
  • Ruling 3 (в) — напоминания «Отложено»: механика и границы «бэкенд/фронт». Окно при переносе в «Отложено» (HoldReminderDialog) — ФРОНТ: после успешного move на hold store.js L19761980 сам открывает окно, если state.remindersEnabled, и никакого напоминания при move не шлёт; бэкенд получает напоминание отдельным POST /{card}/reminder {at} (setHoldReminder L20602089: «через N дней (1–30)» или «дата+время» — расчёт at полностью на клиенте, epoch-ms). Семантика 1:1 с projects.py L236282: (1) set_reminder: если remindersEnabled == false → 400 «Напоминания об отложенных выключены в настройках»; иначе запись reminder_at + reminder_fired=false; стадия карточки НЕ проверяется (фронт шлёт только для hold); (2) clear_reminder и snooze (at = now + 24 ч) выключатель НЕ проверяют (1:1); (3) ЛЮБОЙ move сбрасывает напоминание (reminder_at=NULL, reminder_fired=false — move_stage L210213); (4) фоновая проверка ProjectReminderService.CheckDueAsync: выключено → ТОЛЬКО очистка протухших (reminder_at ≤ now; чтобы при включении старые не «выстрелили»), возврат []; включено → строки stage='hold' AND reminder_fired=false AND reminder_at ≤ now помечаются fired и возвращаются списком [{id,title,stage}]; (5) SSE reminder_due по каждой записи публикует Api-слой (Ruling 5 этапа 3) — в ручном тике и фоновом цикле; ответ POST /admin/tickreminders: [те же записи — «уже выстрелившие», после SSE] (api-map §3.2 L103112); (6) цикл проверки — 30 с в существующем StorageTickScheduler (main.py L4753: тик → тосты → check_reminders), отдельный hosted-сервис НЕ заводим; ручной путь — POST /api/admin/tick (dashboard_routes.py L327337). Настройка — уже готовый публичный ключ SettingsKeys.RemindersEnabled (дефолт true, SettingsDefaults L117; PATCH /api/settings работает с этапа 2).
  • Ruling 4 (г) — файлы: порт IFileStorage, адаптеры, ключи, тип. Новый внешний порт C/Integrations/IFileStorage.cs: PutAsync(objectKey, Stream, contentType, ct) (возвращает objectKey), GetAsync(objectKey, ct) → Stream? (null — объекта нет), DeleteAsync(objectKey, ct) — как object_store.py L61107. Адаптеры в Deal.Infrastructure/Integrations/ (секция AddDealIntegrations/отдельный AddDealFileStorage(IConfiguration, contentRoot)): LocalFileStorage — root data/attachments под ContentRoot (fallback прототипа object_store.py L5479: _local_path строит путь из objectKey и не даёт выйти за root), MinioFileStorage — MinIO S3-клиент (NuGet Minio; ленивая проверка/создание бакета при первом put — object_store.py L2651; креды Storage:Minio {Endpoint, AccessKey, SecretKey, Bucket="deal-files", Secure} из appsettings/env Storage__Minio__*). Выбор на старте: Minio-адаптер регистрируется, только если Endpoint и AccessKey/SecretKey заполнены; иначе LocalFileStorage — dev/curl/unit по умолчанию идут БЕЗ MinIO (требование «заглушка-адаптер, если MinIO недоступен» из roadmap). В deploy/compose.dev.yml добавляется сервис minio (порты 9000/9001, volume deal_minio_data, root-пользователь) — опциональная ручная проверка MinIO-режима. objectKey = projects/{cardId}/{unixMs}_{safeName} — 1:1 с object_store.put L65 (safeName: имя файла санитизируется — path-разделители/кавычки заменяются; единственный бакет и отсутствие tenant-префикса — как в прототипе: бакет один, доступ к объекту только через метаданные карточки в БД тенанта; мульти-аренда объектного хранилища — этап 7 SaaS). Тип файла — чистый FileKindDetector модуля Projects: MIME-префиксы image|video|audio → kind, иначе расширение по наборам files.py L1328 (KIND_BY_EXT, метки KIND_LABELS: Изображение/Видео/Аудио/Архив/Документ/Файл). Метаданные — в ProjectCards.FilesJson (запись {id pf_, name, size, kind, label, objectKey}); значки-счётчики на карточке — длина массивов links/files в ProjectCardDto. Download: stream, application/octet-stream, Content-Disposition: attachment; filename="…" (кавычки имени убираются, projects_routes.py L174179); отсутствие objectKey у записи → 410 «Файл не сохранён в объектном хранилище»; GetAsync == null → 404 «Файл не найден в MinIO» (фиксированная строка прототипа); запись/карточка не найдены → 404 «Карточка не найдена» (прототип на этом пути отдаёт 500 — для .NET выбираем корректный 404, фронт таких запросов не шлёт). Upload — multipart/form-data, поле files (несколько файлов), ответ {items: [файл]}; фронт после upload/delete перечитывает карточку (store.js L20312053).
  • Ruling 5 (д) — «взять в работу». Эндпоинт POST /api/projects/take {leadId} принадлежит модулю Projects (api-map §3.5 L161 — не leads). Поток 1:1 с take_lead_to_projects (projects.py L127156): (1) лид читается через публичный порт Kanban IKanjStore.GetCardAsync — null → 404 «Лид не найден»; (2) по LeadId ищется существующая проектная карточка (IProjectStore.GetByLeadAsync) — есть → возврат её (идемпотентность); (3) создаётся ProjectCard: stage=planned, local=false, title/summary/stack/budget/contact копируются из CardDto лида, comments=[{id cm_, by «Вы», text «Взял в работу из лида.», time «только что»}], history=[{id h_, at, type:"created"}], tzText=""; (4) лид помечается IKanjStore.MarkTakenAsync(leadId) — новый метод порта Kanban (UPDATE Cards SET Col='taken', IsNew=false WHERE Id=?; возвращает bool «строка обновлена»), реализация — в KanbanStore; метод НЕ пишет CardMoves, не трогает matchHits/prevCol/архивные поля (1:1 с проектом L155 — только col и is_new). Гонка двух take: частичный UNIQUE ProjectCards.LeadId (Ruling 1) — вторая вставка падает, сервис перечитывает и возвращает существующую карточку. Никаких журналов/ML-сигналов/SSE при take. matchHits и dedup-связь лида НЕ удаляются (текст остаётся в системе — повтор не заведётся); лид остаётся строкой Cards (col=taken) и уже исключён из списков/поиска/счётчиков (leads.py L151156, L526545; этапы 3–4). Обратного пути «Выбранные → дашборд» НЕТ (ТЗ L124–125). «Отклонено»/«Выполнено» — терминальные стадии проектного канбана; проектные карточки в архив/корзину дашборда не попадают (отдельная таблица, автоархив StorageTickService оперирует только Cards) — StorageTickService/Kanban НЕ меняем.
  • Ruling 6 (е) — ручное создание. POST /api/projects с телом {title, summary, stack?, budget?, contact, tzText?, stage?} (projects_routes.py L2028): local=true, history=[{type:"createdLocal"}], title — Trim(), stage = переданный, если в каталоге ProjectStages, иначе "planned" (create_local_card L103124). Фронт шлёт {title:''} (store.js L19091915) — пустой заголовок допустим (1:1).
  • Ruling 7 (ж) — история движения. Пишется ТОЛЬКО на создание (запись {id h_, at, type:"created"|"createdLocal"}) и на каждую смену стадии (запись {id, at, stage:<новая>}) — move_stage L207215; правка полей, комментарии, ссылки, файлы, напоминания в историю НЕ пишутся (1:1 прототип). Хранится JSON-массивом в карточке; фронт показывает под спойлером «История движения» (ProjectDrawer). Ответы мутаций несут полную history.
  • Ruling 8 (з) — SSE reminder_due. Событие reminder_due несёт {id, title, stage} (api-map §2 L3342; id — проектной карточки, stage всегда "hold"); фронт слушает событие (api.js L62–83) и для баннера берёт карточку из локального projectCards по id (api-map п.5 L395) — публикуем только после того, как карточки ушли в GET /api/projects. Публикации — только из Api (ручной тик AdminTickOrchestrator и StorageTickScheduler); дополнительный toast НЕ шлём (у фронта — модалка ReminderNotice с действиями Открыть/Позже/Снять).
  • Ruling 9 (и) — эндпоинты этапа. Реализуем 16 из 18 эндпоинтов §3.5 (столько вызывает фронт). НЕ реализуем: GET /api/projects/reminders (api-map п.9 L399 — фронт не вызывает: активные напоминания фронт берёт из projectCards; список в настройках-UI отсутствует) и DELETE /api/projects/{card_id} (п.6 L396 — всегда 400 «отключено», фронт кнопки не имеет; по истории правок пользователя «удаление проектной карточки не делаем»). Удаление карточек — только POST /api/projects/clear-rejected (hard-delete строк стадии rejected, 1:1 clear_stage L223231; при пустой стадии {ok:true, cleared:0}). Порядок маршрутов: статические сегменты (/clear-rejected, /take) регистрируются до /{cardId}; вложенные (/move, /comments, /links, /files, /reminder) — за /{cardId} (методы разные, конфликтов GET/POST нет, но соблюдаем конвенцию api-map L19–24). Смежные доработки: POST /api/admin/tick возвращает reminders (Ruling 3), boot-заглушка GET /api/projects удаляется из BootStubEndpoints (остаётся /tg/status — этап 6).
  • Ruling 10 (к) — «жизненный цикл» проектной карточки. Карточка живёт от создания (take/local) до терминальной стадии; hard-delete только через clear-rejected. Никаких автоочисток «Выполнено» (готово живёт в списке). Напоминание не мешает move на другие стадии; переход на терминальную стадию не архивирует и не удаляет карточку (фронт считает её в «всего»). Локальный флаг local (wire) — пометка «создано локально» на карточке (ProjectCard.vue L6170: local, «из лида» = leadId && !local).
  • Ruling 11 (л) — сервисы модуля, id и wire. Проектные id (короткие, генератор PrefixId этапа 3): карточка pr_, комментарий cm_ (общий префикс Kanban), ссылка pl_, файл pf_, история h_ (python store.uid). ProjectCardDto — формы §4.3 (camelCase; createdAt/updatedAt/at — epoch-ms); stack — массив строк; budget — объект {from,to,cur}|null (DTO Kanban CardBudgetDto переиспользуется; Cur пустой строкой означает «нет бюджета» → null наружу); комментарий — форма {id,by,text,time} (DTO Kanban CardCommentDto). Сортировка списка — UpdatedAt DESC, опциональный фильтр ?stage= (list_cards L5863). Настройки модуль читает портом ISettingsStore.GetBoolAsync(SettingsKeys.RemindersEnabled) (дефолт — через SettingsDefaults).
  • Ruling 12 (м) — детерминированная приёмка без внешних сервисов. Unit — fake-зависимости (FakeProjectStore/FakeIFileStorage/FakeKanjStore/FakeSettingsStore); файловая приёмка — локальный режим LocalFileStorage (data/attachments); MinIO-режим проверяется вручную при поднятом compose-сервисе (не входит в обязательную приёмку); напоминания приёмки — ручной POST /admin/tick (фоновый 30-с цикл не ждём).

Задачи

Сокращения путей: P= src/core/Deal.Modules.Projects/, K= src/core/Deal.Modules.Kanban/, S= src/core/Deal.Modules.Settings/, C= src/core/Deal.Contracts/, I= src/core/Deal.Infrastructure/, A= src/core/Deal.Api/, T= src/core/tests/Deal.Tests.Unit/. Отчёты — task-N-report.md в .superpowers/sdd/deal-stage5-projects/.

Task 1: Миграция TenantProjects — таблица ProjectCards

Files:

  • Create: I/Persistence/Entities/ProjectCardEntity.cs и I/Persistence/ProjectCardConfiguration.cs (поля/типы Ruling 1; JSON-колонки .HasColumnType("text"); индексы (Stage), (UpdatedAt) (DESC), частичный UNIQUE (LeadId)HasFilter("\"LeadId\" IS NOT NULL"); ReminderAt — nullable).
  • Modify: I/Persistence/TenantDbContext.cs — DbSet ProjectCards + ApplyConfiguration.
  • EF: миграция TenantProjects для TenantDbContext (как TenantKanban: dotnet ef migrations add TenantProjects --context TenantDbContext --output-dir Migrations/TenantDb --project src/core/Deal.Infrastructure --startup-project src/core/Deal.Api); старт Api применяет её к схеме дефолтного тенанта (провижинер).

Источники: db.py L103125 (таблица projects); projects.py L31100 (_insert/_row_to_card); Ruling 1; эталон: TenantPipeline-миграция, CardEntity/CardConfiguration.

Acceptance: build 0/0; dotnet test MarkerTests PASS; psql (search_path дефолтного тенанта): таблица ProjectCards с PK/колонками; индексы IX_ProjectCards_Stage, IX_ProjectCards_UpdatedAt (DESC), UNIQUE IX_ProjectCards_LeadId (partial: два NULL-а допустимы, два одинаковых LeadId — нет); __TenantMigrationsHistory содержит TenantProjects. Отчёт: task-1-report.md.

Task 2: Модуль Projects — стадии, DTO карточки, порт IProjectStore, реестр

Files:

  • Create: P/Application/ProjectStage.cs (record Id/Name/Color/Terminal) и P/Application/ProjectStages.cs (каталог 9 стадий Ruling 1 в порядке planned→rejected + Contains(stage); 1:1 constants.py L1727/§4.4).
  • Create: P/Application/ProjectIdPrefixes.cs (pr_/pf_/pl_/h_; комментарий — KanbanIdPrefixes.Comment).
  • Create: P/Application/Models/: ProjectFileDto.cs (id/name/size/kind/label/objectKey), ProjectLinkDto.cs (id/name/url), ProjectHistoryEntryDto.cs (id/at; или type="created"|"createdLocal" — запись {Id, At, Type}, либо stage — отдельный record с nullable-полями и фабриками Created(now, local)/Moved(now, stage)), ProjectReminderDto.cs ({At} объект|null на карточке), ProjectCardDto.cs (§4.3: id/stage/local/leadId/title/ summary/stack/budget(CardBudgetDto?)/contact/comments(CardCommentDto[])/links/files/tzText/history/reminder/ createdAt/updatedAt — наружу epoch-ms), ProjectCardRow.cs (полная запись для InsertAsync), ProjectCardPatch.cs (partial-поля правки: title/summary/contact/tzText/stack/budget/comments/links/files).
  • Create: P/Application/IProjectStore.cs — порт: ListAsync(stage?), GetAsync, GetByLeadAsync, CreateAsync(row), PatchAsync(cardId, patch) → bool, MoveStageAsync(cardId, stage, historyEntry, at) → bool (стадия+история+ сброс reminder+bump UpdatedAt), SetReminderAsync(cardId, at) (bump), ClearReminderAsync(cardId), ClearStageAsync(stage) → int, ListDueAsync(now) → мини-DTO {Id,Title,Stage}, MarkFiredAsync(ids), ClearExpiredAsync(now) → int, RemoveAsync(cardId) (откат take, Ruling 5).
  • Create: P/Application/ProjectsModuleRegistrar.csAddProjectsModule() (сервисы задач 4/5/7 — по мере появления). Modify: P/Deal.Modules.Projects.csproj — ProjectReference на Deal.Modules.Settings, Deal.Modules.Kanban, Deal.Contracts.

Источники: api-map §4.3 L280300, §4.4 L302304; projects.py L31100; constants.py L1627; db.py L103125; Rulings 1/2/11.

Acceptance: build 0/0; стадии 1:1 (имена/цвета/terminal, порядок); DTO — record'ы (camelCase); MarkerTests PASS. Отчёт: task-2-report.md.

Task 3: EF-адаптер ProjectStore + DI

Files:

  • Create: I/Persistence/Repositories/ProjectStore.cs — реализация IProjectStore на TenantDbContext (эталон PipelineStore.cs/KanbanStore.cs): чтения AsNoTracking; JSON-опции camelCase (эталон KanbanStore JsonOptions L3842); маппинг строки ↔ ProjectCardDto вручную (JSON-разбор stack/comments/links/files/history, бюджет → CardBudgetDto|null, reminder → ProjectReminderDto|null, времена ↔ epoch-ms); CreateAsync — INSERT; PatchAsync — точечные UPDATE по присутствующим полям патча (текстовые — как есть; stack — сериализация; budget — from/to/cur; comments/links/files — полная замена массива) + bump UpdatedAt; MoveStageAsync — один UPDATE (stage, reminder_at=NULL, reminder_fired=false, updated_at) + перезапись history-массива с добавленной записью; ClearStageAsync — DELETE WHERE Stage=; ListDueAsync — SELECT hold-карточек (ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt); MarkFiredAsync — UPDATE ... SET ReminderFired=true; ClearExpiredAsync — UPDATE ReminderAt=NULL WHERE ReminderAt ≤ now (1:1 check_reminders L266268: fired не важен — чистим все протухшие).
  • Modify: I/ServiceCollectionExtensions.csAddScoped<IProjectStore, ProjectStore>().

Источники: projects.py L31100, L159199, L202231, L264282; Ruling 1/2; эталон KanbanStore.cs/PipelineStore.cs.

Acceptance: build 0/0; EF-путь покрывается psql/curl последующих задач (юнит на EF-адаптерах не пишем — конвенция этапа 4); базовые проверки psql (вставка/патч/move/список). Отчёт: task-3-report.md.

Task 4: «Взять в работу» — порт Kanban MarkTakenAsync + ProjectsService (чтение/создание/take/патч/move/очистка)

Files:

  • Modify: K/Application/IKanjStore.cs — новый метод MarkTakenAsync(string cardId, CancellationToken ct) → Task<bool> (XML-doc: UPDATE Cards SET Col='taken', IsNew=false WHERE Id=? — «взять в работу» projects take_lead_to_projects L155; журнал CardMoves/архивные поля/matchHits не трогает, Ruling 5).
  • Modify: I/Persistence/Repositories/KanbanStore.cs — реализация MarkTakenAsync (affected == 1).
  • Create: P/Application/ProjectsService.cs — публичный сервис (Rulings 5/6/7/10): ListAsync(stage?, ct), GetAsync(cardId, ct); CreateLocalAsync(ProjectCardPatch-начальные поля, ct) (local=true, history createdLocal, stage-валидация); TakeLeadAsync(leadId, ct) (Ruling 5: GetCardAsync → 404-результат; GetByLeadAsync → возврат существующей; CreateAsync с комментарием «Взял в работу из лида.» + history created; MarkTakenAsync — false → RemoveAsync-откат и 404; конфликт UNIQUE LeadId (DbUpdateException) → перечитать GetByLeadAsync); PatchAsync(cardId, patch, ct) (404-результат); MoveAsync(cardId, stage, ct) (валидация ProjectStages → 400-результат; запись истории + сброс reminder); ClearRejectedAsync(ct); методы-результаты — тонкие record-результаты/исключения модуля (эталон CardsService/LeadsEndpoints-паттернов: сервис кидает доменные ошибки, эндпоинт мапит в 400/404 с точными строками).
  • Test: T/FakeProjectStore.cs, T/ProjectsServiceTests.cs (+ расширение T/FakeKanjStore.cs — GetCardAsync/ MarkTakenAsync): take создаёт карточку (поля из лида, local=false, planned, комментарий-«Взял в работу из лида.», history created) и вызывает MarkTakenAsync; повторный take того же лида возвращает ту же карточку (GetByLeadAsync) без новой вставки; лид не найден → 404; create local (local=true, createdLocal, stage из тела/ planned); move (история + запись stage + сброс reminder); move на неизвестную стадию → 400; patch полей (в т.ч. budget {from,to,cur}/null, stack) и bump UpdatedAt; clear-rejected удаляет только rejected и возвращает счётчик.

Источники: projects.py L103124, L127156, L159231; projects_routes.py L78121; leads.py L151156; Rulings 5/6/7/10; эталон CardsService + IKanjStore-порт.

Acceptance: dotnet test новых тестов PASS; build 0/0. Отчёт: task-4-report.md.

Task 5: Комментарии и ссылки (ProjectsService) + тесты

Files:

  • Modify: P/Application/ProjectsService.csAddCommentAsync(cardId, text, ct): пустой после Trim → 400 «Пустой комментарий»; новый {id cm_, by «Вы», text, time «только что»}; ответ — список comments (routes L124128, projects.py add_comment L194199). AddLinkAsync(cardId, name, url, ct): url Trim, пустой → 400 «Пустая ссылка»; без схемы → префикс https://; запись {id pl_, name: name.Trim() или url, url}; через PatchAsync(files-нет → links-замена). RemoveLinkAsync(cardId, linkId, ct) (удаление из массива).
  • Test: T/ProjectsServiceTests.cs — комментарий (id/форма, пустой → 400, 404 карточки), ссылка (префикс https://, name=url по умолчанию, удаление по id, 400 пустой url).

Источники: projects_routes.py L124150; projects.py add_comment L194199, patch_card L159187; Ruling 11.

Acceptance: dotnet test PASS; build 0/0. Отчёт: task-5-report.md.

Task 6: Файлы — порт IFileStorage, Local/MinIO-адаптеры, FileKindDetector, compose-minio, DI

Files:

  • Create: C/Integrations/IFileStorage.cs (Ruling 4; XML-doc: objectKey — opaque, projects/<card>/<ms>_<name>).
  • Create: P/Application/FileKindDetector.cs — чистый детектор: Detect(name, mime) → ProjectFileKind {Kind, Label}; MIME-префиксы image/video/audio; иначе расширение по наборам (1:1 files.py L1328: image/video/audio/ archive/document + «other» → «Файл»).
  • Create: I/Integrations/Storage/StorageOptions.cs (секция Storage: Local {Root} + Minio {Endpoint, AccessKey, SecretKey, Bucket, Secure}), LocalFileStorage.cs (root data/attachments под ContentRoot; Put — mkdir + write, Get — FileStream|null, Delete — unlink; безопасный путь из objectKey: Path.GetFileName сегментов, object_store.py L5479), MinioFileStorage.cs (Minio SDK: ленивый клиент + bucket_exists/make_bucket бакета deal-files, PutObject/GetObject/RemoveObject; NuGet Minio в I/Deal.Infrastructure.csproj).
  • Create: I/Integrations/Storage/FileStorageRegistrar.cs (или в ServiceCollectionExtensions) — метод AddDealFileStorage(IConfiguration, string contentRoot): секция Storage:Minio заполнена → MinioFileStorage, иначе LocalFileStorage (root из Storage:Local:Root или дефолт).
  • Modify: deploy/compose.dev.yml — сервис minio (image minio/minio, container_name deal-minio, порты 9000:9000/9001:9001, env MINIO_ROOT_USER/PASSWORD=deal_minio/deal_minio_secret, volume deal_minio_data, command server /data --console-address ":9001") + volume.
  • Test: T/FileKindDetectorTests.cs (png/jpg/webp → image; mp4 → video; mp3 → audio; pdf/docx/txt → document; zip/7z → archive; mime-image поверх неизвестного расширения; неизвестное → other/«Файл»); T/LocalFileStorageTests.cs (put/get round-trip; get отсутствующего → null; delete; objectKey с .. не выходит за root).

Источники: files.py L1345; object_store.py L26107; ТЗ §4.8 L130; Ruling 4.

Acceptance: dotnet test PASS; build 0/0; запуск Api — LocalFileStorage (лог/путь data/attachments); compose config валиден (docker compose -f deploy/compose.dev.yml config). Отчёт: task-6-report.md.

Task 7: ProjectFilesService — добавить/удалить файл (мета + объект)

Files:

  • Create: P/Application/ProjectFilesService.cs (Ruling 4): AddAsync(cardId, fileName, contentType, dataStream/ bytes, ct) → ProjectFileDto: карточка существует (GetAsync → иначе 404-результат); FileKindDetector.Detect; objectKey = projects/{cardId}/{unixMs}_{safeName} (safeName: имя без path-символов/кавычек); IFileStorage.Put; запись {id pf_, name (как прислано), size (length), kind, label, objectKey} → PatchAsync(files-замена); RemoveAsync(cardId, fileId, ct) — entry из FilesJson → IFileStorage.Delete(objectKey) + PatchAsync(files без записи); GetEntryAsync(cardId, fileId, ct) → (entry|null) для download-эндпоинта. Зависимости: IProjectStore, IFileStorage. (Файл-контент читает эндпоинт из multipart; в сервис приходит Stream + длина.)
  • Test: T/FakeFileStorage.cs, T/ProjectFilesServiceTests.cs: add (детект kind по mime/имени, objectKey-форма, мета в карточке, порядок файлов сохраняется); remove (объект удалён, мета обновлена); 404 карточки; add на несуществующей карточке не пишет объект.

Источники: files.py L5794; object_store.py L61107; projects_routes.py L153186; Ruling 4/11.

Acceptance: dotnet test PASS; build 0/0. Отчёт: task-7-report.md.

Task 8: Эндпоинты /api/projects — карточки, стадии, комментарии, ссылки; замена boot-заглушки; curl-приёмка

Files:

  • Create: A/Endpoints/ProjectsEndpoints.cs (MapProjectsEndpoints, Ruling 9) — 10 эндпоинтов карточек/ комментариев/ссылок (файл- и reminder-эндпоинты — задачи 9/10): GET /api/projects?stage={items:[…]}; GET /api/projects/{cardId} → карточка | 404 «Карточка не найдена»; POST /api/projects (CreateLocalRequest: title/summary/stack?/budget?{from,to,cur}/contact/tzText/stage?) → карточка; POST /api/projects/take {leadId} → карточка | 404 «Лид не найден»; POST /api/projects/clear-rejected{ok:true, cleared}; PATCH /api/projects/{cardId} (PartialUpdateRequest — все поля optional, budget может быть null) → карточка | 404; POST /api/projects/{cardId}/move {stage} → карточка | 400 «Неизвестная стадия» | 404; POST /api/projects/{cardId}/comments {text} → {comments:[…]} | 400 «Пустой комментарий» | 404; POST /api/projects/{cardId}/links {name?,url} → карточка | 400 «Пустая ссылка» | 404; DELETE /api/projects/{cardId}/links/{linkId} → карточка; статические /take+/clear-rejected до /{cardId}. Сессия 401 (эталон LeadsEndpoints/StorageEndpoints: проверка HasUser + RequestServices-резолв ПОСЛЕ).
  • Create: A/Endpoints/RequestModels/{CreateLocalProjectRequest,TakeLeadRequest,MoveStageRequest, ProjectCommentRequest,ProjectLinkRequest,ProjectPatchRequest}.cs.
  • Modify: A/Endpoints/BootStubEndpoints.cs — удалить GET /api/projects-заглушку и константу ProjectsPath (остаётся /api/tg/status; класс-комментарий обновить). Modify: A/Program.csAddProjectsModule(), MapProjectsEndpoints(); A/Deal.Api.csproj — ProjectReference на Deal.Modules.Projects.
  • Test: T/ProjectsEndpointsContractsTests.cs НЕ нужен (endpoint-слои покрываются curl); MarkerTests остаются.

Контракт: api-map §3.5 L155168; §4.3; projects_routes.py L15150.

Acceptance (curl admin/admin): GET /api/projects → {items:[]}; POST /api/projects {title:''} → карточка (local=true, stage=planned, createdLocal-история); PATCH (title/stack/budget) → карточка с изменениями и возросшим updatedAt; POST /move {stage:'work'} → история пополнена {id,at,stage:work}, reminder null; move невалидной стадии → 400; POST /comments (пустой → 400 «Пустой комментарий»; текст → {comments:[…]}); POST /links без схемы → https://…; DELETE /links/{id} → карточка без ссылки; POST /take {leadId=несуществующий} → 404 «Лид не найден»; boot-группа (GET /api/projects) 200 — заглушка снята; 401 без куки. Отчёт: task-8-report.md.

Task 9: Файл-эндпоинты /api/projects/{cardId}/files* — upload/download/delete + curl-приёмка

Files:

  • Modify: A/Endpoints/ProjectsEndpoints.cs — POST /api/projects/{cardId}/files (multipart/form-data, поле files; request.ReadFormAsync; каждый файл: имя/ContentType/Stream → ProjectFilesService.AddAsync); ответ {items:[§4.3 файл]} (404 «Карточка не найдена» при отсутствии карточки); GET /api/projects/{cardId}/files/{fileId}/download — entry через GetEntryAsync: нет записи → 404 «Карточка не найдена»/404 файла нет в метаданных; objectKey пуст → 410 «Файл не сохранён в объектном хранилище»; IFileStorage.GetAsync → null → 404 «Файл не найден в MinIO»; иначе Results.Stream(stream, "application/octet-stream", fileDownloadName: имя без кавычек) (Content-Disposition attachment, 1:1 projects_routes.py L164179); DELETE /api/projects/{cardId}/files/{fileId}{ok:true} (404 карточки). Скачивание: один файл — в ответ Stream (Results.Stream сам диспозит).
  • Modify: DI-проверка — AddDealFileStorage вызван в Program.cs (Task 6; если Task 6 не успел — здесь).

Контракт: api-map L710 (multipart/octet-stream), L169171; projects_routes.py L155186; store.js L20312053.

Acceptance (curl, local-режим): загрузить 2 файла (-F files=@tz.pdf -F files=@photo.png) → {items:[2]}; GET /api/projects/{id} — files с kind/label (document/«Документ», image/«Изображение»), size; download → 200 attachment + байты совпадают; DELETE файла → {ok:true}, карточка без файла, объект удалён из data/attachments; download удалённого → 404. Отчёт: task-9-report.md.

Task 10: Напоминания — ProjectReminderService + эндпоинты reminder/reminder/snooze

Files:

  • Create: P/Application/ProjectReminderService.cs (Ruling 3): SetAsync(cardId, atMs, ct) — GetBoolAsync (ISettingsStore, RemindersEnabled) false → 400-результат «Напоминания об отложенных выключены в настройках»; карточки нет → 404; SetReminderAsync + возврат полной карточки; ClearAsync(cardId, ct) (404-результат); SnoozeAsync(cardId, ct) (now + 24 ч, не проверяет выключатель); CheckDueAsync(ct)IReadOnlyList< ProjectReminderDueDto{Id,Title,Stage}> (Ruling 3: disabled → ClearExpiredAsync + []; enabled → ListDueAsync + MarkFiredAsync + due-список). Константа ReminderSnoozeMs = 24 ч (имя, не магия).
  • Modify: A/Endpoints/ProjectsEndpoints.cs — POST /api/projects/{cardId}/reminder {at: epoch-ms} → карточка | 400 (напоминания выключены) | 404; DELETE /api/projects/{cardId}/reminder{ok:true} | 404; POST /api/projects/{cardId}/reminder/snooze{ok:true} | 404. POST /{cardId}/reminder и DELETE /{cardId}/reminder — до /{cardId}/reminder/snooze (snooze — статический сегмент за параметром).
  • Test: T/FakeSettingsStore.cs — уже умеет задавать значения; T/ProjectReminderServiceTests.cs: set при remindersEnabled=false → 400-текст; set ok → карточка с reminder.at; clear; snooze (+24 ч); CheckDueAsync: disabled → ClearExpired вызван, due пуст; enabled + due-строки → fired проставлены (MarkFired), возвращены {id,title,stage}; не-hold/будущие не «выстреливают».

Источники: projects.py L236282; projects_routes.py L189211; api-map L172174, §4.6 L328/L340; Rulings 3/11.

Acceptance: dotnet test PASS; build 0/0. Отчёт: task-10-report.md.

Task 11: POST /api/admin/tick — reminders + SSE reminder_due

Files:

  • Modify: A/AdminTickOrchestrator.cs — зависимость ProjectReminderService; порядок 1:1 с admin_tick (L327337): (1) Kanban-тик → (2) purge отсева → (3) тосты → (4) check-reminders → SSE reminder_due ({id,title,stage}, broker) по каждому due → (5) pump → (6) new_lead → (7) queue; ответ — reminders списком due (после SSE, api-map §3.2 L103112). Ошибки проверки напоминаний не роняют тик (лог + reminders:[]).
  • Modify: A/AdminTickResultDto.csReminders: IReadOnlyList<object> → типизированный IReadOnlyList<ProjectReminderDueDto> (XML-doc: этап 5 — реальный список).
  • Modify: A/Program.cs — регистрация ProjectReminderService уже через AddProjectsModule (Task 8).

Источники: dashboard_routes.py L327337; projects.py check_reminders L264274; main.py L4753; api-map §3.2; Rulings 3/8.

Acceptance: build 0/0; unit — AdminTickOrchestratorTests (существуют): тик вызывает CheckDueAsync, публикует reminder_due по каждому due, reminders ответа = due; сбой reminder-проверки → reminders:[] без падения тика (обновить тесты под новую зависимость — fake ProjectReminderService). curl: reminder на hold-карточку в прошлом (at=now1 мин) → POST /admin/tick → в SSE-подписке приходит reminder_due, ответ tick содержит reminders:[{id, title, stage:'hold'}]. Отчёт: task-11-report.md.

Task 12: Фоновая проверка напоминаний — StorageTickScheduler (30 с)

Files:

  • Modify: A/Hosting/StorageTickScheduler.cs — в TickTenantAsync после Kanban-тика/purge/тостов: ProjectReminderService.CheckDueAsync из tenant-scope (резолв после SetTenant) → SSE reminder_due в канал тенанта (SseBroker — новая singleton-зависимость конструктора, эталон StorageToastPublisher L3644); ошибки ветки логируются (тик тенанта продолжается, паттерн существующего catch). Порядок 1:1 с _storage_loop main.py L4753 (тик → тосты → напоминания). Класс-комментарий обновить.
  • Modify: A/Program.cs — (регистрация уже есть) AddHostedService остаётся; DI singleton SseBroker уже зарегистрирован.
  • Test: T/StorageTickSchedulerTests.cs — дополнить: тик тенанта вызывает CheckDueAsync и публикует reminder_due по due-записям (fake ProjectReminderService + реальный SseBroker с подпиской, как в существующих тестах тостов); disabled → событий нет.

Источники: main.py L4753; projects.py check_reminders L264274; StorageTickScheduler.cs L152192; Rulings 3/8.

Acceptance: dotnet test PASS; build 0/0; запуск Api: hold-карточка с прошедшим reminder_at → в пределах 30-с тика в SSE-подписке приходит reminder_due (psql: reminder_fired=true). Отчёт: task-12-report.md.

Task 13: Финал этапа — интеграция и сквозная приёмка

  • scripts/build.sh/scripts/test.sh — успешны; dotnet build Deal.sln 0/0; все unit-тесты PASS (535 этапа 4
    • новые).
  • Сквозной curl-сценарий (DEAL_DEMO=1, admin/admin, local-файлы): демо-ingest вакансии → admin/tick → карточка в /leads; POST /api/projects/take {leadId} → проектная карточка (local=false, planned, leadId, история created, комментарий «Взял в работу из лида.»); повторный take → та же карточка; лид исчез из GET /leads и /api/search (taken); GET /api/projects — список (UpdatedAt DESC); PATCH карточки (title/stack/budget/contact/ tzText) → поля обновлены; POST /move по стадиям planned→reply→work→hold (история: 4 записи stage) → reminder на past-время → admin/tick → SSE reminder_due + reminders ответа; move hold→ready (напоминание снято — reminder null); POST /comments, POST/DELETE /links; upload 2 файлов (kind по MIME/расширению) → счётчики в карточке → download (attachment, байты) → DELETE файла; локальная карточка POST /api/projects {title} (local=true, createdLocal); перенос локальной в rejected → POST /clear-rejected {ok, cleared:1}; 401-проверки без куки; GET /api/projects/reminders и DELETE /api/projects/{id} — 404 маршрута нет (сознательно не реализованы, Ruling 9).
  • psql дефолтного тенанта: ProjectCards — строки всех сценариев; reminder_at/fired; UNIQUE-индекс (вставка второго проекта с тем же LeadId → ошибка unique); лид в Cards col='taken' + is_new=false; файлы в data/attachments соответствуют objectKey.
  • Обновить docs/technical/Техническая-документация-Дейл.md: раздел «Projects/„Выбранные“» (таблица ProjectCards, стадии, эндпоинты, напоминания/SSE reminder_due, файлы/IFileStorage/MinIO-compose, take-семантика, исключённые эндпоинты) и зафиксировать roadmap-флаг «этап 5 выполнен» (roadmap L69–73 → «Выполнено»).
  • Отчёт task-13-report.md + финальная строка progress.md.

Self-Review

  1. Spec coverage: ТЗ §4.8 (L119–132): стадии-канбан и терминальные статусы — Rulings 1/10, Task 4; «взять в работу» с уходом лида безвозвратно — Ruling 5, Task 4 (+ исключение из списков/поиска — уже в этапах 3/4); ручное создание «локальных» — Ruling 6, Task 4; редактирование суммы/стека/контактов/ТЗ и комментарии — Tasks 4/5; ссылки и значки-счётчики — Task 5 + DTO; файлы с определением типа (MIME+расширение) и хранением MinIO/локальный fallback — Rulings 4, Tasks 6/7/9; история движения под спойлером (создание/каждая стадия, статус-дата-время) — Rulings 7, Tasks 2/4; напоминания «Отложено» (окно 1–30 дней/календарь — фронт; выключено → окно не показывается и не срабатывают; автоснятие при уходе с hold) — Ruling 3, Tasks 10/11/12; очистка «Отклонено» и «не попадают в архив/корзину» — Rulings 5/10, Task 4. api-map: §3.5 — Tasks 8/9/10; §4.3/§4.4 — Task 2; §2 SSE reminder_due — Rulings 3/8, Tasks 11/12; admin/tick reminders — Task 11; boot-фронт (GET /api/projects в boot L571593) — Task 8. Roadmap этапа 5 (L69–73) — все задачи.
  2. Placeholder scan: Заглушек нет: единственная «заглушка» — dev-файловое хранилище LocalFileStorage по умолчанию (1:1 с прототипом без MinIO, объектный ключ в БД тот же) при полной реализации MinIO-адаптера (включается конфигурацией); GET /api/projects/reminders и DELETE /{card_id} сознательно НЕ реализуются (api-map п.9/п.6, Ruling 9) — это не TODO, а решения. Референсы строк прототипа точные; FIXME/TODO нет.
  3. Type consistency: ProjectCardDto собирается из JSON-полей ProjectCards (тексты wire-форм 1:1 с python, хранятся/читаются с camelCase-опциями адаптера); IProjectStore (Task 2) реализуется ProjectStore (Task 3) без расхождений имён (ListAsync/GetAsync/GetByLeadAsync/CreateAsync/PatchAsync/MoveStageAsync/SetReminderAsync/ ClearReminderAsync/ClearStageAsync/ListDueAsync/MarkFiredAsync/ClearExpiredAsync/RemoveAsync); IKanjStore расширяется одним методом MarkTakenAsync (Kanban не узнаёт о Projects); IFileStorage в Contracts не знает о таблицах (objectKey opaque), метаданные — владение Projects; ModuleProjects csproj → Settings/Kanban/Contracts — циклов нет; SSE-публикации только в Api (AdminTickOrchestrator/StorageTickScheduler), модули чистые; карточки Kanban (Cards) и Projects (ProjectCards) — разные таблицы, Kanban-тик не пересекается; хранилище LocalFileStorage/MinioFileStorage закрывают один порт по конфигурации (на старте) — юнит-тесты на fake.
  4. Вне scope этапа 5: реальные ai/telegram/ml-сервисы и их gRPC-ингресс (этап 6; demo-ingest остаётся источником), discovery (этап 6), telegram-вкладка и /tg/status-реализация (этап 6; boot-заглушка остаётся), события pipeline_stats/boards_changed/leads_reclassified (фронт не слушает), «список активных напоминаний в настройках» (ТЗ L131; у фронта UI нет — GET /reminders не реализуем), DELETE проектной карточки (отключено по решению, п.6), мульти-аренда бакетов/тенант-префиксы объектов MinIO и SaaS-контур (этап 7), загрузка файлов по прямой ссылке в MinIO с подписанными URL (не в прототипе).