Table of Contents
- Дейл (Deal) — Этап 5: Projects («Выбранные»): стадии, напоминания, файлы/ссылки, история, ручное создание Implementation Plan
- Global Constraints
- Зафиксированные решения (Rulings этапа)
- Задачи
- Task 1: Миграция TenantProjects — таблица ProjectCards
- Task 2: Модуль Projects — стадии, DTO карточки, порт IProjectStore, реестр
- Task 3: EF-адаптер ProjectStore + DI
- Task 4: «Взять в работу» — порт Kanban MarkTakenAsync + ProjectsService (чтение/создание/take/патч/move/очистка)
- Task 5: Комментарии и ссылки (ProjectsService) + тесты
- Task 6: Файлы — порт IFileStorage, Local/MinIO-адаптеры, FileKindDetector, compose-minio, DI
- Task 7: ProjectFilesService — добавить/удалить файл (мета + объект)
- Task 8: Эндпоинты /api/projects — карточки, стадии, комментарии, ссылки; замена boot-заглушки; curl-приёмка
- Task 9: Файл-эндпоинты /api/projects/{cardId}/files* — upload/download/delete + curl-приёмка
- Task 10: Напоминания — ProjectReminderService + эндпоинты reminder/reminder/snooze
- Task 11: POST /api/admin/tick — reminders + SSE reminder_due
- Task 12: Фоновая проверка напоминаний — StorageTickScheduler (30 с)
- Task 13: Финал этапа — интеграция и сквозная приёмка
- Self-Review
Перенесено из репозитория (
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 (L153–174), §2 SSE (L33–43: reminder_due = {id, title, stage}), правила
(L7–24: контент-типы multipart/octet-stream, 410/404, «кривые места» L390–400 — п.5 reminder_due, п.6
DELETE-400, п.9 экономия: /projects/reminders НЕ реализуем), §4.3 проектная карточка (L280–300), §4.4
стадии (L302–304), §3.2 admin/tick reminders (L103–112), §4.6 remindersEnabled (L328, L340);
docs/spec/ТЗ.md §4.8 «Выбранные» (L119–132); roadmap (этап 5, L69–73); референс-семантика прототипа:
backend/app/services/projects.py (целиком: _insert/_row_to_card L31–100, create_local_card L103–124,
take_lead_to_projects L127–156, patch_card L159–199, add_comment L194–199, move_stage L202–216,
clear_stage L223–231, напоминания L236–282), backend/app/routers/projects_routes.py (целиком),
backend/app/services/files.py (целиком: KIND_BY_EXT/KIND_LABELS L13–28, detect L31–45, add_file L57–75,
get_file_entry L78–83, remove_file L86–94), backend/app/services/object_store.py (целиком: configured,
put/get/remove, локальный fallback L54–79), backend/app/services/leads.py (L151–156, L526–545 — взятые
исключены из списков/поиска), backend/app/db.py (L103–125 — таблица projects), backend/app/constants.py
(L16–27 — PIPELINE_STAGES), backend/app/sse.py, backend/app/main.py (L47–53 — 30-с цикл с
check_reminders), backend/app/routers/dashboard_routes.py (admin_tick L327–337);
фронт: src/frontend/src/views/ProjectsView.vue (колонки по PIPELINE_STAGES data.js), components/ {ProjectColumn,ProjectCard,ProjectDrawer,HoldReminderDialog,ReminderNotice}.vue, store.js (boot L571–593;
startProject L1954–1966; moveProject L1968–1980; patchProject/addProjectComment/addProjectLink/removeProjectLink
L1985–2027; addProjectFiles/removeProjectFile L2031–2053; setHoldReminder/clearHoldReminder/snooze/
clearDueReminder L2060–2146; startRealtime L670–674 — reminder_due), api.js (openEvents L62–83 — слушает
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-Postgresdeal-postgres(:5433); curl-приёмка :5080 (scripts/build.sh/scripts/test.sh); NuGetMinio— только в этапе файлов (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 с таблицейprojectsdb.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 L71–100). Индексы:(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 L17–27). Пользовательских стадий/досок проектного канбана в прототипе НЕТ. - 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.cs—AddProjectsModule()+MapProjectsEndpoints()+AddDealFileStorage(...)(Task 6/8); Api.csproj — ссылка на модуль. - Ruling 3 (в) — напоминания «Отложено»: механика и границы «бэкенд/фронт». Окно при переносе в «Отложено»
(
HoldReminderDialog) — ФРОНТ: после успешногоmoveна hold store.js L1976–1980 сам открывает окно, еслиstate.remindersEnabled, и никакого напоминания при move не шлёт; бэкенд получает напоминание отдельнымPOST /{card}/reminder {at}(setHoldReminder L2060–2089: «через N дней (1–30)» или «дата+время» — расчётatполностью на клиенте, epoch-ms). Семантика 1:1 с projects.py L236–282: (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 L210–213); (4) фоновая проверкаProjectReminderService.CheckDueAsync: выключено → ТОЛЬКО очистка протухших (reminder_at ≤ now; чтобы при включении старые не «выстрелили»), возврат []; включено → строкиstage='hold' AND reminder_fired=false AND reminder_at ≤ nowпомечаются fired и возвращаются списком[{id,title,stage}]; (5) SSEreminder_dueпо каждой записи публикует Api-слой (Ruling 5 этапа 3) — в ручном тике и фоновом цикле; ответPOST /admin/tick→reminders: [те же записи — «уже выстрелившие», после SSE](api-map §3.2 L103–112); (6) цикл проверки — 30 с в существующемStorageTickScheduler(main.py L47–53: тик → тосты → check_reminders), отдельный hosted-сервис НЕ заводим; ручной путь —POST /api/admin/tick(dashboard_routes.py L327–337). Настройка — уже готовый публичный ключ 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 L61–107. Адаптеры вDeal.Infrastructure/Integrations/(секция AddDealIntegrations/отдельныйAddDealFileStorage(IConfiguration, contentRoot)):LocalFileStorage— rootdata/attachmentsпод ContentRoot (fallback прототипа object_store.py L54–79:_local_pathстроит путь из objectKey и не даёт выйти за root),MinioFileStorage— MinIO S3-клиент (NuGetMinio; ленивая проверка/создание бакета при первом put — object_store.py L26–51; кредыStorage:Minio{Endpoint, AccessKey, SecretKey, Bucket="deal-files", Secure} из appsettings/envStorage__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 L13–28 (KIND_BY_EXT, метки KIND_LABELS: Изображение/Видео/Аудио/Архив/Документ/Файл). Метаданные — вProjectCards.FilesJson(запись {idpf_, name, size, kind, label, objectKey}); значки-счётчики на карточке — длина массивов links/files в ProjectCardDto. Download: stream,application/octet-stream,Content-Disposition: attachment; filename="…"(кавычки имени убираются, projects_routes.py L174–179); отсутствие objectKey у записи → 410 «Файл не сохранён в объектном хранилище»; GetAsync == null → 404 «Файл не найден в MinIO» (фиксированная строка прототипа); запись/карточка не найдены → 404 «Карточка не найдена» (прототип на этом пути отдаёт 500 — для .NET выбираем корректный 404, фронт таких запросов не шлёт). Upload — multipart/form-data, полеfiles(несколько файлов), ответ{items: [файл]}; фронт после upload/delete перечитывает карточку (store.js L2031–2053). - Ruling 5 (д) — «взять в работу». Эндпоинт
POST /api/projects/take {leadId}принадлежит модулю Projects (api-map §3.5 L161 — не leads). Поток 1:1 с take_lead_to_projects (projects.py L127–156): (1) лид читается через публичный порт KanbanIKanjStore.GetCardAsync— null → 404 «Лид не найден»; (2) по LeadId ищется существующая проектная карточка (IProjectStore.GetByLeadAsync) — есть → возврат её (идемпотентность); (3) создаётся ProjectCard: stage=planned, local=false, title/summary/stack/budget/contact копируются из CardDto лида, comments=[{idcm_, by «Вы», text «Взял в работу из лида.», time «только что»}], history=[{idh_, 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: частичный UNIQUEProjectCards.LeadId(Ruling 1) — вторая вставка падает, сервис перечитывает и возвращает существующую карточку. Никаких журналов/ML-сигналов/SSE при take. matchHits и dedup-связь лида НЕ удаляются (текст остаётся в системе — повтор не заведётся); лид остаётся строкой Cards (col=taken) и уже исключён из списков/поиска/счётчиков (leads.py L151–156, L526–545; этапы 3–4). Обратного пути «Выбранные → дашборд» НЕТ (ТЗ L124–125). «Отклонено»/«Выполнено» — терминальные стадии проектного канбана; проектные карточки в архив/корзину дашборда не попадают (отдельная таблица, автоархив StorageTickService оперирует только Cards) — StorageTickService/Kanban НЕ меняем. - Ruling 6 (е) — ручное создание.
POST /api/projectsс телом {title, summary, stack?, budget?, contact, tzText?, stage?} (projects_routes.py L20–28): local=true, history=[{type:"createdLocal"}], title — Trim(), stage = переданный, если в каталоге ProjectStages, иначе "planned" (create_local_card L103–124). Фронт шлёт{title:''}(store.js L1909–1915) — пустой заголовок допустим (1:1). - Ruling 7 (ж) — история движения. Пишется ТОЛЬКО на создание (запись {id
h_, at, type:"created"|"createdLocal"}) и на каждую смену стадии (запись {id, at, stage:<новая>}) — move_stage L207–215; правка полей, комментарии, ссылки, файлы, напоминания в историю НЕ пишутся (1:1 прототип). Хранится JSON-массивом в карточке; фронт показывает под спойлером «История движения» (ProjectDrawer). Ответы мутаций несут полнуюhistory. - Ruling 8 (з) — SSE
reminder_due. Событиеreminder_dueнесёт{id, title, stage}(api-map §2 L33–42; 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 L223–231; при пустой стадии {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 L61–70: 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 L58–63). Настройки модуль читает портом 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— DbSetProjectCards+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 L103–125 (таблица projects); projects.py L31–100 (_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 L17–27/§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.cs—AddProjectsModule()(сервисы задач 4/5/7 — по мере появления). Modify:P/Deal.Modules.Projects.csproj— ProjectReference наDeal.Modules.Settings,Deal.Modules.Kanban,Deal.Contracts.
Источники: api-map §4.3 L280–300, §4.4 L302–304; projects.py L31–100; constants.py L16–27; db.py L103–125; 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 L38–42); маппинг строки ↔ 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 L266–268: fired не важен — чистим все протухшие). - Modify:
I/ServiceCollectionExtensions.cs—AddScoped<IProjectStore, ProjectStore>().
Источники: projects.py L31–100, L159–199, L202–231, L264–282; 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 L103–124, L127–156, L159–231; projects_routes.py L78–121; leads.py L151–156; 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.cs—AddCommentAsync(cardId, text, ct): пустой после Trim → 400 «Пустой комментарий»; новый {idcm_, by «Вы», text, time «только что»}; ответ — список comments (routes L124–128, projects.py add_comment L194–199).AddLinkAsync(cardId, name, url, ct): url Trim, пустой → 400 «Пустая ссылка»; без схемы → префиксhttps://; запись {idpl_, 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 L124–150; projects.py add_comment L194–199, patch_card L159–187; 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 L13–28: image/video/audio/ archive/document + «other» → «Файл»). - Create:
I/Integrations/Storage/StorageOptions.cs(секция Storage: Local {Root} + Minio {Endpoint, AccessKey, SecretKey, Bucket, Secure}),LocalFileStorage.cs(rootdata/attachmentsпод ContentRoot; Put — mkdir + write, Get — FileStream|null, Delete — unlink; безопасный путь из objectKey: Path.GetFileName сегментов, object_store.py L54–79),MinioFileStorage.cs(Minio SDK: ленивый клиент + bucket_exists/make_bucket бакетаdeal-files, PutObject/GetObject/RemoveObject; NuGetMinioв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 L13–45; object_store.py L26–107; ТЗ §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; запись {idpf_, 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 L57–94; object_store.py L61–107; projects_routes.py L153–186; 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.cs—AddProjectsModule(),MapProjectsEndpoints();A/Deal.Api.csproj— ProjectReference наDeal.Modules.Projects. - Test:
T/ProjectsEndpointsContractsTests.csНЕ нужен (endpoint-слои покрываются curl); MarkerTests остаются.
Контракт: api-map §3.5 L155–168; §4.3; projects_routes.py L15–150.
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 L164–179); DELETE/api/projects/{cardId}/files/{fileId}→{ok:true}(404 карточки). Скачивание: один файл — в ответ Stream (Results.Stream сам диспозит). - Modify: DI-проверка — AddDealFileStorage вызван в Program.cs (Task 6; если Task 6 не успел — здесь).
Контракт: api-map L7–10 (multipart/octet-stream), L169–171; projects_routes.py L155–186; store.js L2031–2053.
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 L236–282; projects_routes.py L189–211; api-map L172–174, §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 (L327–337): (1) Kanban-тик → (2) purge отсева → (3) тосты → (4) check-reminders → SSEreminder_due({id,title,stage}, broker) по каждому due → (5) pump → (6) new_lead → (7) queue; ответ — reminders списком due (после SSE, api-map §3.2 L103–112). Ошибки проверки напоминаний не роняют тик (лог + reminders:[]). - Modify:
A/AdminTickResultDto.cs—Reminders: IReadOnlyList<object>→ типизированныйIReadOnlyList<ProjectReminderDueDto>(XML-doc: этап 5 — реальный список). - Modify:
A/Program.cs— регистрация ProjectReminderService уже через AddProjectsModule (Task 8).
Источники: dashboard_routes.py L327–337; projects.py check_reminders L264–274; main.py L47–53; 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=now−1 мин) → 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) → SSEreminder_dueв канал тенанта (SseBroker— новая singleton-зависимость конструктора, эталон StorageToastPublisher L36–44); ошибки ветки логируются (тик тенанта продолжается, паттерн существующего catch). Порядок 1:1 с _storage_loop main.py L47–53 (тик → тосты → напоминания). Класс-комментарий обновить. - Modify:
A/Program.cs— (регистрация уже есть) AddHostedService остаётся; DI singleton SseBroker уже зарегистрирован. - Test:
T/StorageTickSchedulerTests.cs— дополнить: тик тенанта вызывает CheckDueAsync и публикует reminder_due по due-записям (fake ProjectReminderService + реальный SseBroker с подпиской, как в существующих тестах тостов); disabled → событий нет.
Источники: main.py L47–53; projects.py check_reminders L264–274; StorageTickScheduler.cs L152–192; 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.sln0/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
- 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 L571–593) — Task 8. Roadmap этапа 5 (L69–73) — все задачи. - Placeholder scan: Заглушек нет: единственная «заглушка» — dev-файловое хранилище LocalFileStorage по умолчанию (1:1 с прототипом без MinIO, объектный ключ в БД тот же) при полной реализации MinIO-адаптера (включается конфигурацией); GET /api/projects/reminders и DELETE /{card_id} сознательно НЕ реализуются (api-map п.9/п.6, Ruling 9) — это не TODO, а решения. Референсы строк прототипа точные; FIXME/TODO нет.
- 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. - Вне 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 (не в прототипе).