Imported from azizmac/Flow (
AGENTS.md). Install upstream withnpx skills add azizmac/Flow. Copyright stays with the author.
Flow — трекер задач по проектам (C#, .NET 10)
Слойная архитектура
Flow.Api → единственный backend-хост: контроллеры (только IMediator + IActorAccessor), MCP-сервер (Mcp/*, /mcp), Auth-модуль, fallback-политика «всё закрыто», Auth/ClaimsActorAccessor, Auth/ApiExceptionFilter, Bootstrap/BootstrapOwnerSeeder (миграции ядра + профиль bootstrap-пользователя)
Flow.Auth → Razor Class Library: ASP.NET Core Identity + BCrypt, OpenIddict server и локальная validation (code+PKCE, refresh, client_credentials), страницы входа/смены пароля, AuthDbContext в схеме auth; подключается через AddAuthModule
Flow.Auth.Contracts→ публичная граница Auth-модуля: IAccountService и AccountResult
Flow.Shared → DTO-контракты (Boards, Tasks, Users) для API и клиента
Flow.Application → Features/{Boards,Tasks,Users,Bootstrap,Search,Attachments,About}/{Commands,Queries}/*, Features/Tasks/Mentions (MentionParser/MentionResolver), Abstractions (IBoardRepository, IBoardMemberRepository, ITaskItemRepository, ITaskCommentRepository, ITaskActivityRepository, IUserRepository, IUnitOfWork, IActorAccessor, IPermissionService), Security (PermissionService, ActorResolver, ProjectAccess, ProjectRoles), Exceptions (UnauthorizedActorException → 401, ForbiddenException → 403)
Flow.Domain → сущности Board, BoardMember, ProjectRole, ProjectPermission, Status, TaskType, TaskTypeKind, DefaultTaskTypes, TaskPriority, TaskItem, TaskCode, StatusType, DefaultStatuses, TaskComment, TaskCommentMention, TaskActivity, TaskActivityType, Attachment, User, UserLink, UserLinkType, UserRole, UserStatus, UserPreferences, SidebarMode, StartPage, Dashboard, DashboardWidget, WidgetType
Flow.Infrastructure→ EF Core (Postgres/Npgsql + pgvector), репозитории, UnitOfWork, миграции, Search/* (эмбеддер, очередь и воркер индексации)
Flow.Client → библиотека Razor-компонентов (Microsoft.NET.Sdk.Razor), не приложение: хостит и рендерит её Flow.Api, режим InteractiveServer с намеренно выключенным prerender. Экраны «Проекты» (/ и /boards), «Задачи» (/tasks и /boards/{id} — один Pages/Tasks.razor), задача (/tasks/{id}), «Люди» (/users), профиль (/users/{id}), поиск (/search), настройки (/settings); UI-кит MudBlazor 9.x поверх своих токенов (Services/FlowTheme.cs), сервисы интерфейса регистрирует DependencyInjection/AddFlowClientServices — своего Program.cs у библиотеки нет. За данными страницы ходят через Services/IFlowApi; реализация InProcessFlowApi лежит в Flow.Api (клиент по слоям видит только Shared) и зовёт IMediator в том же процессе, без HTTP
Зависимости: Application → Domain + Shared + Auth.Contracts; Infrastructure → Application; Auth → Auth.Contracts; Api → Application + Infrastructure + Auth + Shared.
Shared намеренно не ссылается на Domain (свои enum StatusType, TaskTypeKind, TaskPriority, UserLinkType, UserRole, UserStatus, TaskActivityType; маппинг — UserMappingExtensions.ToResponseRole/ToDomainRole/ToResponseStatus, TaskMappingExtensions.ToResponseType/ToResponsePriority/ToDomainPriority, BoardMappingExtensions.ToResponseKind/ToDomainKind).
Ключевые инварианты Board (aggregate root)
Key:^[A-Z][A-Z0-9]{1,9}$, префикс для TaskCode.Create(name, key)— единственная публичная точка создания; сажает 4 статуса изDefaultStatusesи 5 типов задач изDefaultTaskTypes.- Типы задач (docs/TZ_task_model.md §1, этап 1A) —
Board.TaskTypes, как статусы:TaskType(Name ≤ 50, уникально в проекте без учёта регистра,Kind : TaskTypeKindEpic/Story/Task/Bug/Subtask,SortOrder,IsDefault,IsArchived). Уровень иерархии выводится из вида (TaskTypeKind.Level(): эпик 1 → история 2 → задача/ошибка 3 → подзадача 4) и в БД не хранится; иконка и цвет — тоже от вида (клиент,Services/TaskMeta). Ровно один тип по умолчанию (AddTaskType(isDefault)/SetDefaultTaskTypeпереносят флаг), тип по умолчанию не архивируется; удаления нет — толькоSetTaskTypeArchived.CreateTask(..., typeId)без типа — тип по умолчанию, архивный или чужой —InvalidOperationException. EF:TaskType.Id—ValueGeneratedNever(), иначе новый тип, добавленный в уже сохранённую доску, EF считает существующим и шлёт UPDATE на 0 строк. - Ровно 1 начальный и минимум 1 финальный статус на доску (см. «Статусы проекта»).
CreateTask: безstatusId→ в начальный;NextTaskNumber++→ TaskCode; новые задачи регистрируются черезITaskItemRepository.Add, не через коллекциюBoard.Tasks.- Все поля
{ get; private set; }, мутации только через методы.
User (профиль, не учётная запись)
User.Create(username, email, firstName, lastName)— единственная точка создания; username/email нормализуются в lower, username^[a-z0-9][a-z0-9._-]{0,30}[a-z0-9]$.- Внешние ссылки — коллекция
UserLink(SetLink/RemoveLink), не более одной наUserLinkType; телефон хранится в E.164 (+79991234567). - Роль и статус (
docs/TZ_user_roles.md, #15):User.Role : UserRole(Reader 0 … Owner 4, порядок = сила, сравнивать>=; по умолчанию Member) иUser.Status : UserStatus(Invited при создании → Active после первогоGET /users/meчерезMarkActive()→ Deactivated).IsActive/CanBeAssignedвычисляются изStatus(builder.Ignore), в БД их нет — в запросах фильтровать поStatus != Deactivated.ChangeRoleтолько меняет роль; «последний Owner» и права — Application (#18).Deactivate()бросает для Owner (сначала понизить) и повторно;Activate()— только из Deactivated;MarkActive()для Deactivated — ошибка. Пароли в домене отсутствуют намеренно (живут в Flow.Auth). Из UI деактивация убрана до #20. - Личные настройки интерфейса — value object
User.Preferences : UserPreferences(SidebarModeAuto/Expanded/Collapsed,StartPageProjects/Tasks/MyTasks/Dashboard,TasksPageSizeизAllowedTasksPageSizes= 10/25/50/100,TasksViewList/Board/Tree/Backlog/Calendar/Roadmap — вид экрана «Задачи»), меняется целиком черезChangePreferences(→bool, no-op без сохранения). В EF — complex type, колонкиPrefSidebarMode/PrefStartPage/PrefTasksPageSizeпрямо вUsers(миграцияAddUserPreferences, существующим профилям — размер 100, а не 0). КомандыUserGetPreferencesQuery/UserUpdatePreferencesCommand(PATCH-семантика) работают только с самим actor:UserIdу них нет, строки в матрице прав тоже — свои настройки меняет любая роль, чужие не читает никто. Клиент:Services/PreferencesState(одинGETна circuit; режим меню уходит вAppState.SetSidebarMode), размер страницы читаетTasks.razorдо монтирования таблицы, стартовая страница действует только на корень/(Boards.razorуводит наtasksилиtasks?who={me}),/boardsи логотип ведут на проекты всегда. TaskItem.CreatedById : Guid?(FK → Users, Restrict, индекс) — кто создал, для «своей задачи» у Member;Board.CreateTask(..., createdById).TaskCreateпишет туда actor.- Планирование задачи (этап 1A):
TypeId(FK → TaskTypes, Restrict;ChangeType— тип того же проекта, не архивный),Priority : TaskPriority(None 0 … Critical 4, фиксированная шкала, не справочник),StartDate : DateOnly?(SetSchedule(start, due): начало ≤ срок;SetDueDateпроверяет то же),StoryPoints : decimal?(0…999.9, шаг 0.1,numeric(5,1)),EstimateMinutes : int?(0…999 рабочих дней по 8 ч; учёта фактического времени нет намеренно),UpdatedAt— ставитUnitOfWork(TaskItem.Touch) у каждой задачи в состоянии Modified, а не доменные методы: новое поле не сможет забыть «тронуть» дату. Комментарии и вложенияUpdatedAtне двигают. МиграцияAddTaskTypesAndPlanning(Up переписан руками: типы по умолчанию каждому проекту, задачам — тип «Задача»,UpdatedAt = CreatedAt).
Пользовательские поля (docs/TZ_task_model.md §4, этап 1D)
CustomFieldDefinition— часть агрегатаBoard(таблицаCustomFields:Keyпо^[a-z][a-z0-9_]{1,29}$, неизменяем, unique(BoardId, Key);Type : CustomFieldTypeText/LongText/Number/Date/Select/MultiSelect/User/Checkbox/Url — не меняется;Options— jsonb черезOwnsMany(...).ToJson();TaskTypeIds—uuid[], пусто — все типы;IsRequired,SortOrder,IsArchived). Мутации —Board.AddCustomField/UpdateCustomField/ReorderCustomFields, удаления нет.CustomField.Id—ValueGeneratedNever,BoardRepositoryгрузит поля вместе с доской.- Значения —
TaskItem.CustomFieldsJson(колонкаCustomFields jsonb, GINjsonb_path_ops), ключ — Id поля. Менять толькоTaskItem.SetCustomField(→ старое/новое JSON или null):CustomFieldValidatorв Domain нормализует (Text ≤ 500, LongText ≤ 10000, Url http/https, Dateyyyy-MM-dd, Select/User — Id строкой, MultiSelect — массив Id, пустое — очистка), обязательное не очищается. Удалённый вариант задачи не трогает — клиент показывает «(удалённое значение)». - Application:
Features/CustomFields—CustomFieldCreate/Update/Reorder(ManageConfig, ответBoardResponse),TaskSetCustomFields(EnsureCanEditTask). Путь записи один —TaskCustomFields.ApplyAsync(человек дляUser— активный, журналCustomFieldChanged,Upsertв поиске при правке Text/LongText);TaskCustomFields.EnsureRequired— вTaskCreate(значения приходят в команде) и вTaskUpdateпри смене типа. Текстовые значения — в чанке задачи (SearchSourceReader). - FQL
cf.<key>: SQL-функцииflow_cf_text/number/date/anyсоздаёт миграцияAddCustomFields, в EF они —Persistence/CustomFieldSql(DbFunction).TaskFilterCustomField(FieldIds, Type, Op, Value)— все поля с этим ключом в видимых проектах. ILIKE всюду с явнымESCAPE '\': без него Npgsql ставитESCAPE '', и%/_в строке становились шаблоном. - Клиент:
Components/CustomFieldsDialog(значок «Поля проекта» в топбаре «Задач», ManageConfig),CustomFieldsEditor— пары meta-k/meta-v в сетке слайдера, карточки и формы создания (компактныеMudTextField/MudNumericField/MudDatePicker/MudSelect/MudCheckBox— у фасадаDsInputвид поля формы, а не строки мета-сетки),Services/CustomFieldMeta(подписи, отображение), журнал «поле «X»: A → B».
Иерархия и ручной порядок (docs/TZ_task_model.md §3, §7, этап 1B)
TaskItem.ParentId : Guid?(FK на себя, Restrict, индекс) — родитель в том же проекте и строго выше по уровню типа (TaskTypeKind.Level()), поэтому циклов и глубины больше четырёх нет по построению.SetParent(parent, ownType, parentType)— типы приносит хендлер;ChangeType(type, parentLevel, minChildLevel)держит уровень между родителем и детьми. Команды:TaskCreate(…, ParentId),TaskSetParent(журналParentChangedу задачи,ChildRemoved/ChildAddedу старого и нового родителя; индекс не трогает),TaskDelete(…, Cascade)— с детьми без флага 400, с флагом хендлер сам собирает поддерево (каскада в БД нет: он прошёл бы мимо очереди индексации и S3) и проверяет права на каждой задаче.TaskResponseнесётParentId,ChildCount,ChildDoneCount(CountChildrenAsync— один GROUP BY с join статусов; в ответах команд 0, клиент переносит счётчикиTaskMeta.WithCountsOf).TaskTreeQuery→GET /boards/{id}/tree?rootId=— рекурсивный CTE с путём из рангов через/(меньше любой цифры base-62),COLLATE "C". Экран дерева (этап 2C) — см. «Дерево».TaskItem.Rank : string— дробный индексFlow.Domain/Ranking/FractionalIndex(порт алгоритма Гринспена: целая часть переменной длины + дробная, base-62 в порядке ASCII). Колонкаvarchar(200) COLLATE "C"(по правилам локали 'a' < 'B', и порядок ключей ломается — тест гоняется на ICU-базе), unique(BoardId, Rank)=TaskItemConfiguration.RankIndexName. Ключ считает сервер: новая задача — в конец проекта (GetMaxRankAsync),TaskRankCommand(…, AfterId?, BeforeId?)→POST /tasks/{id}/rank— второго соседа берёт сам (GetNeighborRankAsync). Гонка:UnitOfWorkпереводит 23505 по этому индексу вRankConflictException,TaskRanks.SaveAsyncпересчитывает ключ по свежим соседям, до трёх попыток. Смена одного рангаUpdatedAtне двигает и в журнал не пишется.TaskSortField.Rank, фильтрGET /tasks?parentId=. МиграцияAddTaskHierarchyAndRank: существующим задачамd0001…поCreatedAtвнутри проекта.- Клиент:
Components/ParentSelect(поиск черезGET /tasks, только уровнем выше),Components/SubtaskList(прогресс, быстрый ввод подзадачи — типTaskMeta.ChildTypeFor), чип «2/5» в строке списка, тексты журнала со ссылками на задачи, подтверждение удаления с поддеревом. Слайдер зовётOnHierarchyChanged— список перечитывается.
Слияние, разделение, перенос (docs/TZ_task_model.md §6, этап 1E)
Features/Tasks/Restructure:TaskMergeCommand(дубль закрывается первым финальным статусом и связьюDuplicates; комментарии —TaskComment.TransferToс «из КОДА», вложения без повторов по хешу, связи и подзадачи переходят к основной; с предком/потомком и подзадачами, которые не станут ниже основной, — 400 до изменений),TaskSplitCommand(1–20 частей: родитель, тип, приоритет, активный исполнитель, поля; связьSplitFromу каждой, журналSplitу исходной, она не закрывается),TaskMoveCommand+TaskMovePreviewQuery(поддерево целиком,TaskMovePlanner: статус — карта → вид → начальный, тип — карта → вид → по умолчанию, поля — тот же ключ и тип, варианты по подписи, иначе потеря; подзадача не ниже родителя — 400; спринт и веха сбрасываются),TaskGetByCodeQuery. Права: слияние — правка обеих; разделение — правка + CreateTask; перенос — правка каждой задачи поддерева + CreateTask в целевом.- Прежний код —
TaskCodeAlias(таблицаTaskCodeAliases, PK код, каскад от задачи, миграцияAddTaskCodeAliases);GetByCodeAsyncищет живой код, потом алиас. Вложения:Attachment.Relocate+IFileStorage.CopyAsyncдо транзакции, старые ключи удаляются после коммита. Комментарии и вложения для правки —GetByTaskIdForUpdateAsync(обычные списки читаются без трекинга). Индекс:Upsertзадач, комментариев и вложений с новым проектом. ЖурналMerged/Split/Moved. - API: POST
/tasks/{id}/merge,/tasks/{id}/split(201),/tasks/{id}/move/preview,/tasks/{id}/move; GET/tasks/by-code/{code}. Клиент:RowMenu.ShowRestructure,Components/TaskRestructure+SplitTaskDialog/MergeTaskDialog/MoveTaskDialog,Pages/TaskByCodePage(/tasks/{код}).
Повторяющиеся задачи (docs/TZ_task_model.md §9, этап 1F)
- Образец — сама задача с правилом
TaskRecurrence(одно на задачу, каскад от неё):RecurrenceRulecomplex type (FrequencyDaily/Weekly/Monthly/Yearly,Interval1…99,WeekDaysбитовой маской,MonthDay1…31 или -1),StartsOn/EndsOn,LeadDays0…60,DueOffsetDays0…365,CopyAssignee,CopyChecklist,IsActive,CreatedById,GeneratedUntil,LastError. ВхожденияTaskRecurrenceOccurrences— PK(RecurrenceId, OccursOn),TaskIdSetNull: идемпотентность и защита от возврата удалённой копии. МиграцияAddTaskRecurrences. - Генерация —
RecurrenceGenerateCommandпо одному правилу (воркерInfrastructure/Recurrence/RecurrenceWorker,Recurrence:{Enabled, IntervalMinutes=15, TimeZone=Europe/Moscow}, scope на правило): копии на даты(GeneratedUntil, сегодня + LeadDays], не старше 7 дней; копия — название, описание, тип, приоритет, поля, родитель (если ниже по уровню), снятый чек-лист, активный исполнитель; статус начальный, срок = дата + DueOffsetDays, автор — автор правила, связьClones→ образец, журналCreatedсNewValue = "по расписанию",Upsertв поиске. Образца нет или автор деактивирован —Pauseс причиной;TaskRecurrenceSetсIsActiveвозобновляет от имени actor'а. Права —EnsureCanEditTaskобразца. - API: GET/PUT/DELETE
/tasks/{id}/recurrence, GET/POST/tasks/{id}/recurrence/preview?count=. Клиент:TaskRecurrenceBlock+RecurrenceDialog,Services/RecurrenceText.
Интеграция с Git-хостингами (docs/TZ_git_integration.md, этапы 5A–5E)
- Код и контракты области именуются
Git*, namespaces и папки —GitIntegration. SQL-таблицы —GitHostConnections,GitRepositories,GitRepositoryBoards,GitDevelopmentLinks,GitIntegrationJobs; переход со старых имёнScm*с сохранением данных выполняет миграцияRenameScmTablesToGit. Маршруты области —/api/gitи/hooks/git. Исторические миграции, секция настроекScm, переменнаяSCM_PUBLIC_BASE_URLи DataProtection purposeFlow.Scmсохранены для совместимости с развёрнутой системой. - Доступ к Git-сущностям разделён по отдельным репозиториям:
IGitHostConnectionRepository,IGitRepositoryCatalog,IGitRepositoryBoardRepository,IGitDevelopmentLinkRepository,IGitIntegrationJobRepository. EF-реализации — вInfrastructure/Persistence/Repositories, общийFlowDbContextи сохранение черезIUnitOfWork. Каталог хранитGitRepository; задания интеграции — вебхуки и дозагрузка истории. Тесты и фейки этой области именуютсяGit*. - Модель —
Domain/GitIntegration/:GitHostConnection(GitHub/GitLab/Gitea/Forgejo,BaseUrlдля self-hosted, токен —SecretProtected),GitRepository(вебхук и его секрет,IsActive; отключение не удаляет связи),GitRepositoryBoard(привязка к проекту — коды ищутся только в привязанных проектах),GitDevelopmentLink(Branch/Commit/PullRequest, unique(TaskId, RepositoryId, Kind, ExternalId), каскад от задачи),GitIntegrationJob(очередь, jsonb-Payload— уже нормализованныйGitEvent). МиграцияAddScmIntegration. Права: подключения — глобальные Admin+ (EnsureCanManageIntegrations), привязка —ProjectPermission.ManageGit(администратор проекта), «Разработка» — видимость проекта. Features/GitIntegration:TaskCodeDetector,GitSignatures(HMAC-SHA256 / токен GitLab, постоянное время),GitPayloadParser,GitWebhookReceiveCommand(подпись → повтор по Id доставки → нормализация → очередь),GitIntegrationJobProcessCommand(ветки, коммиты — плюс коммиты ветки с кодом, PR; автор по e-mail или логину из ссылок профиля; удалённая ветка закрывает связь), админ-команды подключений/репозиториев иTaskDevelopmentQuery. Коды находятся и по алиасу после переноса задачи.- Приём —
Flow.Api/Controllers/GitWebhookController(POST /hooks/git/{id}вне/api, аноним + подпись, 5 МБ: 202/200 повтор/401/404/413), шифрование —Flow.Api/GitIntegration/DataProtectionGitSecretProtector, клиенты API —Infrastructure/GitIntegration/GitProviderClient, разбор —GitWorker. НастройкиScm:{PublicBaseUrl, WorkerEnabled, PollIntervalSeconds, DeliveryRetentionDays}; в compose —SCM_PUBLIC_BASE_URL. - Клиент: «Настройки → Интеграции» (Admin+), «Репозитории проекта» в топбаре «Задач», блок «Разработка» (
Components/TaskDevelopment), значок PR (TaskResponse.PullRequestState) в списке и на канбане. - Этап 5B. Дозагрузка истории — не отдельная таблица, а
GitIntegrationJobс событиемflow:backfillв той же очереди: ставится при новой привязке иPOST /git/repositories/{id}/backfill(второе поверх стоящего — нет), разбирается тем жеProcessor(IGitProviderClient.GetHistoryAsync: 100 PR + коммиты ветки по умолчанию за 30 дней,Scm:Backfill*). Лимит запросов хостинга (429; 403 с нулевым*RateLimit-RemainingилиRetry-After) —GitRateLimitException→GitIntegrationJob.Postponeбез траты попытки. GitHub App —GitHostConnection.AuthKind/AppId/InstallationId(миграцияAddScmGitHubApp), секрет — PEM, JWT RS256 → токен установки, кэшGitHubAppTokens(singleton). Диагностика —FailedDeliveriesу репозитория,POST /git/deliveries/{id}/retry(только Failed), клиентComponents/GitIntegrationJobsDialog. FQLdevelopment = openPR | mergedPR | noPR—TaskFilterPullRequest, EXISTS поGitDevelopmentLinks. - Этап 5C. Автоматизация привязки — колонки
GitRepositoryBoard.OnPullRequestOpenedStatusId/OnPullRequestMergedStatusId(FK SetNull на статус) иSmartCommits, PUT/boards/{id}/repositories/{repoId}с телом (пустое — только привязать); миграцияAddScmAutomation. Логика —Features/GitIntegration/GitAutomationчерезTransitionGuard(обхода нет), отказ —GitDevelopmentLink.Note, не ошибка доставки. Actor автоперехода — автор PR с правом править задачу, иначеGitIntegrationBot(фиксированный Id, деактивированный профиль без учётки, создаётся лениво, в workflow — роль Member). Смарт-коммиты —SmartCommitParser, только ветка по умолчанию, только сопоставленный активный автор с его правами, без повтора для уже виденного коммита. Журнал —TaskActivity.Source/SourceUrl(FromSource, «PR #42», «коммит a1b2c3d»). Дозагрузка истории автоматизацию не запускает. - Этап 5D. Действия из Flow —
Features/GitIntegration/GitTaskActions(TaskGitBranchCreateCommand,TaskGitPullRequestCreateCommand): право проектаProjectPermission.WriteGit(17, Developer+), только активный репозиторий, привязанный к проекту задачи; имя ветки —GitUrls.ValidateBranch; связь пишется сразу (автор — actor), новый PR проходит автопереход «PR открыт» от имени создавшего; отказ хостинга (GitProviderException) → 400 с его текстом; ответ —TaskDevelopmentResponse(он же несётRepositoriesиCanWrite). Описание PR и комментарий —GitUrls.TaskCommentс адресомGitOptions.TaskUrl({PublicBaseUrl}/tasks/{КОД}). Комментарий в новом PR — флаг привязкиGitRepositoryBoard.CommentOnPullRequests(миграцияAddScmPullRequestComments): после разбора доставки на новую связь PR (не история, не закрытый, не если адрес задачи уже в описании), отказ —GitDevelopmentLink.Note. Клиент — «Создать ветку»/«Создать PR» вTaskDevelopment→Components/GitCreateDialog, галочка «Комментарий в новом PR» вBoardRepositoriesDialog. - Этап 5E. PR и коммиты в поиске —
SearchSourceType.Development(6), источник —GitDevelopmentLink(ветки не индексируются). Текст —SearchSourceReader.ReadDevelopmentAsync(«PR #42 Заголовок» + ветки; первая строка коммита + короткий sha), проект и закрытость — от задачи. Очередь —Features/GitIntegration/GitSearch(Upsert/Deleteрасширения надISearchIndexQueue): разбор доставки — новая связь или изменившийся текст (дозагрузка — приоритет 1), PR из Flow,TaskDelete(Delete связей),TaskMove(Upsert с новым проектом). Старые связи ставит миграцияIndexScmLinksInSearch. Выдача — joinGitDevelopmentLinksвSearchQueryRepository(ParentId= задача). Клиент — вкладка «Разработка» в поиске,SearchPresentation.KindLabel.
Связи и чек-лист задачи (docs/TZ_task_model.md §5, §8, этап 1C)
TaskLink— отдельная сущность (Source, Target,TaskLinkTypeBlocks/Duplicates/RelatesTo/Clones/SplitFrom, CreatedBy), задачи могут быть в разных проектах.TaskLink.Createзапрещает связь на себя и упорядочивает концы у симметричнойRelatesTo. БД: FK на обе задачи cascade (удаление задачи/проекта уносит связи), unique(Source, Target, Type), индекс(Target, Type). КомандыTaskLinkCreate(вторая задача по Id или коду;Inward— «эта задача заблокирована»; скрытая или несуществующая — 400; дубль — 409; цикл Blocks — не отказ, аCycleWarning, обход в ширину до 500 задач) иTaskLinkDelete(правка любой из двух задач).TaskLinkListQuery— обе стороны,TaskLinkPeer.Restrictedдля задачи в невидимом проекте (без кода и названия). ЖурналLinkAdded/LinkRemoved— у обеих задач:OldValue= вид со стороны задачи (Blocks/Blocks:in),NewValue= Guid второй.TaskResponse.BlockedByCount— входящие Blocks от незакрытых задач, GROUP BY с join статусов.- Чек-лист — owned-коллекция
TaskItem.Checklist(TaskChecklistItem: Text ≤ 500, IsDone, SortOrder, DoneAt, DoneById; таблицаTaskChecklistItems, Id —ValueGeneratedNever), до 100 пунктов, методыAdd/Edit/SetDone/Remove/ReorderChecklist. КомандыTaskChecklist{Add,Update,Delete,Reorder}отвечают чек-листом целиком; журналChecklistChanged(«3/5» → «4/5») — только когда меняется прогресс.UnitOfWork«трогает»UpdatedAtзадачи и при правке её пунктов (owned-записи — отдельные записи трекера).TaskResponse.ChecklistDone/ChecklistTotalточны и в ответах команд. - Счётчики ответов для чтения собирает один
Features/Tasks/TaskResponses(комментарии, подзадачи, блокировки — по GROUP BY на каждый); новый счётчик добавлять туда, а не в хендлеры запросов. - Клиент:
Components/TaskChecklist,Components/TaskLinks(вид связи сегментом, поиск второй задачи по всем видимым проектам), чипы «заблокирована» и «☑ 4/5» в строке списка, тексты журнала;TaskTimeline.NoteActivityдописывает локальные записи.
Фильтр задач, FQL и сохранённые фильтры (docs/TZ_task_views.md §7, этап 2A)
Features/Tasks/Fql:FqlTokenizer→FqlParser(рекурсивный спуск, OR < AND < NOT, скобки,IN (…),IS [NOT] EMPTY,= EMPTY, функции,ORDER BY) →FqlBinder(имена в Id черезIFqlLookup; реализацияFqlLookupвидит только проекты actor'а — ключ скрытого проекта отвечает «не найден») →TaskFilterNode(Abstractions/TaskFilter.cs: And/Or/Not, In по ссылкам, IsEmpty, виды, Compare, Text, Blocked, PullRequest) +TaskOrder. Ошибки —FqlException(message, position, length)→ 400FqlErrorResponse, вInProcessFlowApi—ApiResult.FqlError. Всё чистые функции, тесты без БД. Каталог полей и словарей —FqlFields(один на биндер и подсказки);sprint/milestoneотвечают «появится на этапе…». Относительные даты от UTC-даты сервера;created = день— интервал суток; оценка —30m/2h/1d(день = 8 ч).TaskListFilter.Condition/Orders←TaskSearchQuery.Fql(GET /tasks?fql=): условие складывается с панелью через AND и действует и на страницу, и на счётчики; ORDER BY из FQL заменяет колонку сортировки (несколько ключей). SQL строитInfrastructure/.../TaskFilterTranslatorвыражениями (значения через замыкание — параметры, а не константы; C#-семантика null у отрицаний:assignee != Xвключает задачи без исполнителя).childrenOf,linkedTo,blockedByбиндер разворачивает в наборы Id заранее.- Подсказки —
FqlSuggester.Analyze(слот у курсора по последним токенам) +FqlSuggestQuery→GET /tasks/query/suggest?q=&pos=. SavedFilter(Name ≤ 80, Query FQL ≤ 4000, View, Private/Shared) иSavedFilterStar(избранное),Features/Filters: личный чужой — 404, править — автор, удалить — автор или Admin+ для общего; запрос проверяется биндингом при сохранении. Общий фильтр хранит запрос, а не задачи — каждому считается его правами. API/filters(+PUT/DELETE /filters/{id}/star). МиграцияAddSavedFilters.- Клиент:
Components/FqlBar(обычный input с подсказками; CodeMirror-режим FQL отложен) — второй режим поля поиска в топбаре «Задач», а не отдельная полоса: чип «FQL» вSearchBox(OnFql) ставит строку на место поля (при включённой панели — «Показать как FQL»: панель и набранный текст переезжают в запрос), крестик строки возвращает обычный поиск; текст ошибки с подчёркнутым местом —Components/FqlErrorLineпод рядом фильтров,FilterMenu,SaveFilterDialog,Services/SavedFiltersState, раздел «Фильтры» в сайдбаре (избранные). Адрес:?fql=,?filter={id}— правки открытого фильтра живут в?fql=до «Сохранить».
Статусы проекта (docs/TZ_workflow_config.md §1, этап 3A)
- Статусы — часть агрегата
Board; мутаторыStatusinternal, менять только черезBoard:AddStatus,RenameStatus,SetStatusFinal,SetInitialStatus,SetStatusType,ReorderStatuses(полная перестановка),RemoveStatus(id, moveTo). Инварианты: ровно один начальный, минимум один финальный (их может быть несколько), начальный не бывает финальным, имена уникальны без учёта регистра.ReorderStatusesвыдаётSortOrderвыше текущего максимума — так unique(BoardId, SortOrder)переживает перестановку без DEFERRABLE.Status.Id—ValueGeneratedNever(иначе новый статус существующего проекта уходит UPDATE'ом). - Команды
StatusCreate/Update/Delete/Reorder— правоEnsureCanManageConfig, ответBoardResponse. Удаление переводит задачи статуса (ITaskItemRepository.GetByStatusIdAsync) вmoveToсStatusChangedв журнале иUpsertв очереди поиска; сменаIsFinalставитUpsertзадачам статуса (меняетсяIsClosed). Эндпоинты: POST/boards/{id}/statuses, PATCH/DELETE/boards/{id}/statuses/{statusId}(DELETE?moveTo=), PUT/boards/{id}/statuses/order. Клиент —Components/StatusesDialog(+StatusTypeSelect), подписи видов —StatusColors.TypeLabel.
Workflow (docs/TZ_workflow_config.md §2, этап 3B)
Board.WorkflowMode(Free — как было, Restricted — только по переходам) иBoard.Transitions(StatusTransition:FromStatusId?— null значит «из любого»,ToStatusId,Name, условияMinRole/RequireAssignee/RequireChildrenDone/RequireChecklistDoneколонками). Меняется только целиком —Board.SetWorkflow(mode, specs): в Restricted без тупиков (DeadEnds()— нефинальный статус без исходящих), иначе 400 с именами.CheckTransition(from, to, ctx)— прямые, потом глобальные, хватает любого с выполненными условиями; причины отказа — русским текстом. Admin/Owner граф не обходят: обход — это переход сMinRole = Adminв самом графе.RemoveStatusуносит переходы статуса;CreateTaskв Restricted не в начальный статус — только при глобальном переходе в него.- Контекст собирает
Features/Tasks/TransitionGuard(роль в проекте, исполнитель, чек-лист; подзадачи грузит, только если их требует какой-то переход). Путей смены статуса два —TaskUpdateи перенос на канбане (TaskRankCommandсоStatusId), проверка до любых правок, отказ →TaskUpdateResult.TransitionNotAllowed→ 400{ message, reasons }; служебный перенос при удалении статуса не проверяется. Новый путь (канбан 2B, смарт-коммиты) обязан звать тот же guard. StatusTransitions: FK на доску и оба статуса cascade, unique(BoardId, FromStatusId, ToStatusId)сNULLS NOT DISTINCT;StatusTransitionConfiguration, миграцияAddWorkflow. Клиент:Pages/WorkflowPage(/boards/{id}/workflow: режим, матрица с «Из любого», условия по шестерёнке, граф SVG,<text>через MarkupString: Razor его резервирует),StatusSelectв Restricted выключает недоступные статусы с причиной (TaskId/Restricted).- Workflow по типу задачи (этап 3E):
TaskType.OwnWorkflowMode(null — живёт по проектному) иStatusTransition.TaskTypeId(null — проектный); unique(BoardId, TaskTypeId, From, To)NULLS NOT DISTINCT, миграцияAddTypeWorkflows. Домен —WorkflowModeFor/TransitionsFor/HasOwnWorkflow/ResetTypeWorkflow,SetWorkflow(…, typeId?),CheckTransition(…, typeId?);TransitionGuardпередаётtask.TypeId, поэтому все пути смены статуса учитывают тип сами. API:GET /boards/{id}/workflow?typeId=(inherited),PUTсtaskTypeId,DELETE ?typeId=;TaskTypeResponse.HasOwnWorkflow; клиент — «Workflow для» (?type=) наWorkflowPage. - Редактируемый граф (этап 3D):
Status.GraphX/GraphY(null — автораскладка, миграцияAddStatusGraphLayout),Board.SetStatusLayoutцеликом; раскладка — полеlayoutтого жеPUT /boards/{id}/workflow(null — не менять,[]— авто) иWorkflowResponse.Layout. Перетаскивание узлов и протягивание перехода —flow.graphAttach(без Drawflow: свой SVG, формула дуги продублирована в JS —graphEdgePath=WorkflowPage.EdgePath), в .NET — толькоOnNodeMoved/OnConnect.
Шаблоны проектов и перенос конфигурации (docs/TZ_workflow_config.md §4, этап 3F)
- Чертёж —
Flow.Domain/Templates/BoardBlueprint(статусы, режим, переходы, типы, поля, экраны,SampleTasks; всё связано локальными ключами, у снимка — Id исходного проекта строкойN, у встроенных — слова),Version= 1.Board.ToBlueprint()— снимок (архивное не входит, на экранахcustom:Id→custom:ключ);Board.Create(name, key, blueprint)— проект сразу по чертежу. Живому проекту —ApplyBlueprintStatuses(bp, map)(карта «статус проекта → ключ чертежа», по умолчаниюSuggestStatusMap: имя → вид → финальный → начальный; лишние статусы возвращаются вRemovals, их задачи переносит вызывающий и зовётRemoveStatus),ApplyBlueprintTypes/Fields(только добавляют и правят; поле с тем же ключом другого вида — отказ),ApplyBlueprintWorkflow/Screens(заменяют целиком; совпадающий экран правится на месте),Map*— без правок. - Встроенные шаблоны —
Domain/Templates/BuiltInBoardTemplates(постоянные Id0f10a000-…-00000000000N): «Простой», «Kanban» (WIP), «Scrum», «Баг-трекер» (Restricted, поля, экран создания). Сохранённые —BoardTemplate(таблицаBoardTemplates,Payload jsonb+Version, миграцияAddBoardTemplates); JSON —Features/Templates/BoardBlueprintJson(enum строками, апгрейдер версий, версия новее текущей — отказ). Битый шаблон из списка выпадает, а не роняет его. - Команды —
Features/Templates:BoardTemplateList/Save/Delete(сохранить и удалить — глобальный Admin+ черезEnsureCanCreateBoard, плюс ManageConfig проекта; встроенный не удаляется — 400),BoardCreateCommand(…, TemplateId)(неизвестный — 400; образцы задач — в начальный статус с журналомCreated),BoardApplyConfigPreviewQuery/BoardApplyConfigCommand(ConfigPartsфлагами). Применение —BoardConfigApplier: служебный перенос задач лишних статусов сStatusChangedиUpsert, переиндексация статусов со сменой финальности. Права на все цели проверяются до первой правки; каждая цель — своя транзакция (IUnitOfWork.InTransactionAsync), отказ —DiscardChangesи строка результата, остальные идут дальше; нет изменений по снимку до/после — ничего не пишется (Changed = false). Имена статусов пишутся в два шага (Board.ParkStatusNames/RestoreStatusNames): обмен имён — цикл по unique(BoardId, Name), одним UPDATE его не сохранить. - API: GET
/board-templates, DELETE/board-templates/{id}, POST/boards/{id}/save-as-template(201),/boards/{id}/apply-config/preview,/boards/{id}/apply-config; POST/boardsпринимаетtemplateId. Клиент: выбор шаблона вCreateBoardDialog(статусы, типы, поля под выбором, «Удалить шаблон» у сохранённого),SaveTemplateDialogиApplyConfigDialogиз меню карточки проекта (RowMenu.ShowSaveTemplate/ShowApplyConfig).
Шаблоны задач (docs/TZ_workflow_config.md §5, этап 3G)
TaskTemplate— отдельный агрегат проекта (таблицаTaskTemplates, миграцияAddTaskTemplates): Name ≤ 80 (уникально в проекте без учёта регистра — хендлер),TitlePattern≤ 500 с{date}(дд.мм.гггг, дата UTC) и{n}(UsageCount + 1,RenderTitle),TypeId?(FK SetNull),Priority,Description, значения полей — jsonbCustomFields(как у задачи, нормализуетCustomFieldValidator), чек-лист —text[], подзадачи — jsonbSubtasks(TaskTemplateSubtask(Title, TypeId?, Checklist), до 20),SortOrder,UsageCount. Типы шаблона и подзадач — свои, не архивные, подзадача строго ниже типа шаблона.- Права —
ProjectPermission.ManageTaskTemplates(Developer+,EnsureCanManageTaskTemplates); список видят все, кто видит проект.Features/TaskTemplates/TaskTemplateFeatures.cs: List/Create/Update (полная замена)/Delete иTaskTemplateFromTaskCommand(снимок задачи: тип, приоритет, описание, живые поля, чек-лист, прямые подзадачи по рангу). - Создание по шаблону —
TaskCreateCommand.TemplateId: название, тип, приоритет, описание и поля присылает форма (человек мог поправить), сервер добавляет чек-лист и подзадачи той же транзакцией (тип подзадачи — свой, если ниже родителя, иначе ближайший уровнем ниже, при равных — по умолчанию; обязательные поля — значением родителя; журналCreated+ChildAdded,Upsertв поиске; ранги сразу за родителем, пересчитываются вместе с ним при конфликте) иMarkUsed. Шаблон чужого проекта — 400. Повторяющиеся задачи по-прежнему живут от задачи-образца. - API: GET/POST
/boards/{id}/task-templates(201), PUT/DELETE/task-templates/{id}, POST/tasks/{id}/save-as-template(201); POST/boards/{id}/tasksпринимаетtemplateId. Клиент: «Шаблон» в форме создания (TaskDrawer, заполняет поля заново),TaskTemplatesDialog(значок «Шаблоны задач» в топбаре «Задач»),SaveTaskTemplateDialogиз меню задачи (RowMenu.ShowSaveTemplate).
Экраны задач (docs/TZ_workflow_config.md §3, этап 3C)
TaskScreen— часть агрегатаBoard(таблицаTaskScreens:TaskTypeId?— null значит «все типы»,ContextCreate/Detail,Fields— jsonb спискаScreenField(Field, Required, Section), гдеField=system:<имя>илиcustom:<Id поля>; unique(BoardId, TaskTypeId, Context)NULLS NOT DISTINCT,Id—ValueGeneratedNever). Мутации —Board.SetScreen(целиком) иResetScreen; выбор —Board.ResolveScreen(typeId, context): тип → все типы → встроенныйScreenFields.Default(все поля в прежнем порядке, проект без экранов выглядит как раньше). Скрытое поле значение не теряет.- На Create — только
ScreenFields.OnCreate(тип, приоритет, исполнитель, описание) и пользовательские поля; их «обязательные» проверяетTaskCreateCommandHandler(Board.MissingOnCreateScreen, 400). Исполнитель теперь приходит вTaskCreateCommand.AssigneeId(права —EnsureCanAssign, человек активен), без отдельного PATCH. «Обязательное» на Detail — только подсветка.meta-k.req. - Команды
ScreenSet/ScreenReset(ManageConfig, ответBoardResponse, в нёмScreens). Клиент считает раскладку сам —Services/ScreenLayoutповторяет правило сервера;TaskDrawer,TaskPageи форма создания рисуют строки по раскладке (SystemValue(name)— фрагмент на системное поле,CustomFieldsEditor FieldIds=[id]— на пользовательское), статус всегда первым. Редактор —Components/ScreensDialog(значок «Экраны задач» в топбаре «Задач»). - Переход с
RequireFields(колонкаuuid[]уStatusTransitions):Board.CheckTransitionсмотритTransitionContext.FilledFields(заполненные поля задачи — собираетTransitionGuard), причина «Заполните поле «X»»; вWorkflowPage— мультиселект «Заполнены поля». МиграцияAddScreensAndRequireFields.
Канбан (docs/TZ_task_views.md §1, этап 2B)
- Экран «Задачи» — виды список, доска, дерево, бэклог, календарь и роадмап (
?view=list|board|tree|backlog|calendar|roadmap, по умолчаниюUserPreferences.TasksView, колонкаPrefTasksView). Доска —Components/KanbanBoardнаMudDropContainerповерхGET /tasks/board(TaskBoardQuery): в проекте колонки — статусы, карточки поRank; в «Все проекты» — виды статусов + «Другие» (без вида, пустая скрыта), карточки поUpdatedAt. Фильтры те же, что у списка (FQL-ORDER BY не действует); первые 50 на колонку +Count, догрузка — колонка сoffset. - Финальные колонки показывают задачи, сменившие статус за
Board.DoneColumnDays(по умолчанию 14;PUT /boards/{id}/done-column-days, ManageConfig) — поTaskItem.StatusChangedAt, который ставитUnitOfWorkпри измененииStatusId(фильтрTaskListFilter.DoneWindowAt, окно — из проекта каждой задачи).Status.WipLimit(1…999, PATCH статусаwipLimit/clearWipLimit) — мягкий: только красный заголовок. МиграцияAddKanban(StatusChangedAt — из журнала). - Перенос карточки —
TaskRankCommand(…, StatusId): смена статуса черезTransitionGuard, журналStatusChanged,Upsertв поиске; без соседей можно только соStatusId. Отказ —TaskUpdateResult.TransitionNotAllowed→ 400{ message, reasons }(общийSendUpdateи в контроллере, и вInProcessFlowApi). Клиент: оптимистично, откат с тостом; колонки, куда нельзя, тускнеют при взятии карточки (GET /tasks/{id}/transitions), сброс — поTransactionEndedконтейнера:dragendMudBlazor до родителя не доходит. ЗоныMudDropZoneпосле смены списка нужноRefresh()— сами не перерисуются. - Дорожки (исполнитель/родитель/приоритет) раскладывает клиент по загруженным карточкам; через дорожки не переносят. В «Все проекты» перенос только меняет статус (первый статус вида в проекте задачи), в «Другие» бросать нельзя. Клавиатура на карточке: ←/→ — соседняя колонка, ↑/↓ — соседняя карточка, Enter — слайдер. «+ Задача» внизу колонки — быстрое создание в статусе.
Спринты и бэклог (docs/TZ_task_views.md §2, этап 2D)
Sprint— отдельный агрегат (не коллекцияBoard): Name ≤ 60, Goal ≤ 500,StartDate/EndDate : DateOnly?,StatePlanned/Active/Completed,SortOrder.Start— только из Planned, даты обязательны, снимокSprintCommitments(Kind = Committed, StoryPoints);Complete(openTasks, moveOpenTo)— незакрытые уходят в другой незавершённый спринт или в бэклог и пишутсяCarriedOver(без этого отчёт не собрал бы «не сделано» после переноса); закрытые остаются навсегда. Один Active на проект — хендлер (400) и частичный uniqueIX_Sprints_BoardId_Active. Удалить можно только Planned (задачи — в бэклог с журналом).TaskItem.SprintId(FK SetNull,SetSprint— тот же проект, не в завершённый); журналSprintChanged(Id спринта строкой, null — бэклог).- Права —
ProjectPermission.ManageSprints(Developer+,EnsureCanManageSprints); перенос задачи — правка задачи (EnsureCanEditTask). Оба пути переноса (TaskSetSprintиTaskRankCommandсоSprintId/ToBacklog) идут черезFeatures/Sprints/TaskSprints.MoveAsync— новый путь обязан звать его же. BacklogQuery(GET /boards/{id}/backlog, фильтры списка +epicId): секции активный → запланированные → «Бэклог» (незакрытые без спринта), внутри —Rank, суммы и нагрузка по исполнителям — сервер. Эпик задачи — ближайший предок вида Epic поGetTreeAsync, сами эпики в секции не попадают.SprintReportQuery(GET /sprints/{id}/report): взято/добавлено/сделано/не сделано (задачи и SP), burndown откатом журнала (StatusChanged,SprintChanged,StoryPointsChanged) назад от текущего состояния по концам дней; кандидаты —GetTaskIdsWithValueAsync(SprintChanged, id)+ нынешние задачи спринта. Точки — на весь спринт, у будущих днейRemaining = null; идеальная линия — прямая от SP на старте.- FQL
sprint: имя,openSprints(),closedSprints(),EMPTY(IFqlLookup.SprintsAsync,FqlLookupпринимаетISprintRepository). МиграцияAddSprints. - Клиент:
?view=backlog(TaskView.Backlog, только в проекте) —Components/BacklogView(панель эпиков,MudDropContainerс зонами «спринт/backlog», оптимистичный перенос с откатом,SprintDialogдля создания/правки/старта/завершения наMudDatePicker+Ru.PickerCulture), полеSprintSelectв карточке и слайдере,Pages/SprintReportPage(/boards/{id}/sprints/{sprintId}/report: плитки,MudChartлинией — палитра#D07E1F/#3A9D6Eпроверена против тёмной поверхности, «идеальная» пунктиром, таблица задач).
Вехи (docs/TZ_task_views.md §6, этап 2E)
Milestone— отдельный агрегат: Name ≤ 80 (уникально в проекте без учёта регистра —MilestoneNames, плюс unique(BoardId, Name)), Description ≤ 2000,TargetDate,StateOpen/Closed (Close/Reopen),SortOrder.TaskItem.MilestoneId(FK SetNull, индекс),SetMilestone— веха того же проекта, в закрытую новые не добавляются. ЖурналMilestoneChanged(Guid строкой). Удаление — через хендлер: снимает веху с задач с журналом, FK SetNull только страхует.- Права —
ProjectPermission.ManageMilestones(Developer+,EnsureCanManageMilestones); поле задачи —EnsureCanEditTask, путь один —Features/Milestones/TaskMilestones.MoveAsync. - Прогресс —
ITaskItemRepository.CountByMilestonesAsync(один GROUP BY с join статусов) +MilestoneProgressCalculator(прогноз по закрытым за 14 дней,StatusChangedAt); любойMilestoneResponseсобирается только через него. Команды и запросы — одним файломFeatures/Milestones/MilestoneFeatures.cs, какFilters. - FQL
milestone: имя,openMilestones(),closedMilestones(),EMPTY.FqlFields.Futureпуст (teamпоявился на этапе 4D). МиграцияAddMilestones. - Общие вехи (этап 2H):
Milestone.SharedBoardIds(uuid[], миграцияAddSharedMilestones),IsAvailableIn(boardId)вSetMilestone/TaskMilestones;GetByBoardAsyncотдаёт и общие;PUT /milestones/{id}/boards(MilestoneShareCommand: ManageMilestones во владельце и в каждом добавляемом, занятое имя — 400, из убранного проекта задачи выходят с журналом); видно из любого проекта вехи, правит владелец. - Клиент:
Pages/MilestonesPage(/boards/{id}/milestones, значок в топбаре «Задач»),Pages/MilestonePage(/milestones/{id}: плитки,MilestoneProgressBar—MudProgressLinearс буфером «в работе», задачи по FQL),MilestoneDialog, полеMilestoneSelectв карточке и слайдере.
Календарь и роадмап (docs/TZ_task_views.md §4–5, этап 2F)
- Календарь:
TaskCalendarQuery→GET /tasks/calendar?from=&to=(+ фильтры списка): задачи, чей отрезок[StartDate ?? DueDate, DueDate ?? StartDate]пересекает окно (≤ 100 дней), лимит 1000 иTruncated; условие окна — узлыTaskFilter(TaskCalendarQueryHandler.Window), без своего SQL. Клиент —Components/TaskCalendar(TaskView.Calendar,?view=calendar, в т. ч. «Все проекты»): месяц/неделя, задача в каждом дне отрезка, 4 в ячейке + «ещё N», перенос на день —MudDropContainer→PATCH /tasks/{id}/scheduleсо сдвигом обеих дат. - Роадмап:
Components/TaskRoadmap(TaskView.Roadmap, только в проекте) — поверхGET /boards/{id}/tree, вех проекта иGET /boards/{id}/blocks(стрелки Blocks внутри проекта,BoardBlockLinksQuery). Выведенные даты «по подзадачам» (пунктир) считает клиент обратным проходом по обходу; шкала неделя/месяц/квартал, «сегодня», вехи, «Без дат». Этап 2H: полосы тянутся (тело — обе даты, края — одна, ромб — единственная, «Без дат» — на день шкалы неделей) черезflow.roadmapAttach— движение в JS, в .NET только итог в днях →PATCH /tasks/{id}/schedule; выведенные не тянутся, право —CanEditотTasks.razor.
Дашборды (docs/TZ_task_views.md §8, этап 2G)
Dashboard(Name ≤ 80,Visibility : SavedFilterVisibility,IsDefault— стартовый у владельца) иDashboardWidget(WidgetTypeTaskList/Counter/Breakdown/CreatedVsClosed/SprintBurndown/MilestoneProgress/Markdown, Title ≤ 80,Configjsonb, X/Y/W/H в сетке 12 колонок, H ≤ 8), до 30 виджетов; без места — первое свободное. ТаблицыDashboards/DashboardWidgets, миграцияAddDashboards.Features/Dashboards/DashboardFeatures.cs(CRUD, первый свой — стартовый) иWidgetDataQueryHandler: данные — правами смотрящего, ошибка виджета — полеerror(200), не исключение. Конфиг чистится по виду при сохранении, FQL биндится правами автора. Разбивка —ITaskItemRepository.GroupCountAsync(GROUP BY;cf:<ключ>— в памяти по jsonb), не большеDashboardLimits.MaxGroups= 7 групп + «Другие»; «создано / закрыто» —DailyCountsAsync(закрытые — финальный статус поStatusChangedAt); burndown и веха — черезSprintReportQuery/MilestoneGetQuery. Права —EnsureCanEditDashboard(автор),EnsureCanDeleteDashboard(автор, общий — ещё Admin+).- Клиент:
/dashboards,/dashboards/{id}(CSS grid, строка 90px),DashboardWidgetView,WidgetDialog,DashboardDialog, общийBurndownChart;StartPage.Dashboard(PrefStartPage= 3) уводит корень на свой стартовый дашборд. Этап 2H: автор переставляет виджеты перетаскиванием (шапка — место, уголок — размер) черезflow.gridAttach, итог в клетках → PATCH виджета; наложения сервер не запрещает.
Дерево (docs/TZ_task_views.md §3, этап 2C)
- Третий вид экрана «Задачи» —
?view=tree(TaskView.Tree), только внутри проекта: в «Все проекты» кнопка выключена, вид из настроек падает на список.Components/TaskTreeповерхGET /boards/{id}/tree— тот жеMudDataGrid(.flow-grid tree-grid), что у списка: строки — видимые узлы в порядке обхода (Items), отступ и стрелка — в первой колонке, прогресс —MudProgressLinear Color.Success. TaskTreeQueryпринимает фильтры списка (исполнитель, текст, вид типа, приоритет, статус, FQL — его ORDER BY не действует) иMaxDepth. Дерево проекта берётся целиком (GetTreeAsync), подходящие Id —ITaskItemRepository.MatchingIdsAsyncтем же SQL-фильтром, что список; дальше в памяти одним обратным проходом: узел виден, если подходит или подходит кто-то в его поддереве (тогдаIsContextOnly),VisibleChildCount— видимые дети,Progress— всё поддерево без фильтра (задачи и story points, всего/закрыто).MaxDepthрежет по глубине от корня обхода.- Клиент грузит два верхних уровня (
maxDepth = 1), ветку глубже — при раскрытии (rootId= узел,maxDepth = 1, глубины сдвигаются к месту узла); при фильтре или раскрытых глубоких ветках — всё сразу. Смена статуса в строке перечитывает дерево (прогресс предков). Клавиатура: ↑/↓, → раскрыть/к ребёнку, ← свернуть/к родителю, Enter — слайдер. Родитель меняется полем в слайдере, перетаскивания нет.
Права (docs/TZ_user_roles.md, #18)
- Все изменяющие команды Boards/Tasks/Users начинаются с
Guid ActorId(контроллеры берут его изIActorAccessor.Require()— claimsub). Запросы (чтение) тоже начинаются сActorId(этап 4B): им он нужен для видимости приватных проектов. - Хендлер:
ActorResolver.ResolveAsync(ActorId)(нет профиля или Deactivated →UnauthorizedActorException→ 401) →IPermissionService.Ensure*(→ForbiddenException→ 403) → бизнес-инварианты (InvalidOperationException→ 400). Матрица глобальная:EnsureCanCreateBoardAdmin+;EnsureCanManageUsers/EnsureCanCreateUserAdmin+ (Admin не выдаёт Admin/Owner, роль не выше своей);EnsureCanEditProfileсам или Admin+;EnsureCanEditCredentials(username/email/пароль) сам или Owner;EnsureCanDeactivateOwner;EnsureCanChangeRoleAdmin+, не выше своей, себя не повышать, Admin — только цели и роли ниже Admin. - «Последний Owner» — в
UserChangeRoleCommandHandlerчерезCountByRoleAsync(400). Owner нельзя деактивировать (домен;UserDeactivateпроверяет до вызова Auth-модуля).UserChangePassword: свой — с текущим паролем (иначе 400), чужой — сброс Owner'ом без текущего. - Где что проверяется, чтобы одно правило не проверялось дважды: инвариант сущности — в
Flow.Domain.Tests; поведение хендлера (лимиты, права, очередь индексации, журнал) — вFlow.Application.Testsна фейках; SQL, каскады, миграции и всё, что зависит от Postgres или S3, — вFlow.Infrastructure.Tests;Flow.Api.Tests— только то, чего нет ниже: коды ответов, заголовки, авторизация и разбор query-string. Api-тест, повторяющий бизнес-правило, — это дубль: правило уже проверено там, где живёт. - Права внутри проекта (docs/TZ_project_access.md, этап 4A) — не по
User.Role, а по роли в проекте:ProjectRole(Viewer < Member < Developer < Admin), права —ProjectPermission, роль → набор прав —Security/ProjectRoles.PermissionsOf(таблица только на сервере; клиент получает список прав). Роль считаетIProjectAccess.GetAsync(actor, boardId)= максимум из «по умолчанию» (UserRole.ToProjectRole(): Reader→Viewer, Member→Member, Developer→Developer, Admin/Owner→Admin; срезается потолкомBoard.DefaultRole, кроме глобальных Admin+) и прямого участияBoardMember(PK(BoardId, UserId), отдельная сущность, не коллекция доски). Без участников и потолка роль равна глобальной — поведение прежнее, старыеPermissionTestsне менялись. Запретов (deny) нет. Кэша нет намеренно: scope не всегда равен запросу (корневой провайдер в тестах), а снятие участника должно действовать сразу. Проверки:EnsureCanCreateTask(access);EnsureCanEditTask/Assign(actor, access, task)— EditAnyTask/AssignAnyone либо EditOwnTask на своей задаче (удаление задачи — это правка, как раньше);EnsureCanComment(access),EnsureCanEditComment— автор с правом Comment,EnsureCanDeleteComment— автор или DeleteAnyComment;EnsureCanAttach,EnsureCanDeleteAttachment— аналогично;EnsureCanRenameBoard(RenameProject),EnsureCanManageConfig(типы задач),EnsureCanManageMembers(access, role)— роль не выше своей;EnsureCanDeleteBoard(actor, access)— глобальный Admin+ и DeleteProject. Хендлер берёт роль поBoardIdзадачи/вложения/комментария (для комментария — через его задачу). Участника нельзя добавить деактивированным или несуществующим (400); уже добавленный остаётся (история), права отсекает ActorResolver. - Приватные проекты (этап 4B):Board.Visibility : BoardVisibility(Open 0 — как было, Private 1;PUT /boards/{id}/visibility, ManageMembers). В приватном проекте роль даёт только участие (роль по умолчанию не действует), глобальные Admin/Owner — администраторы и там;ProjectRoles.Effectiveвозвращаетnull— проект не виден. Невидимое — 404, а не 403: запросы чтения отвечают null/пусто, а любаяEnsure*на скрытом проекте бросаетProjectNotFoundException(→ 404 вApiExceptionFilterиInProcessFlowApi.Guard), иначе код задачи в ссылке подтверждал бы её существование. Все запросы чтения получаютActorId:BoardList/Get,TaskList/Get/Search,TaskCommentList,TaskActivityList,AttachmentList/Meta/Content(значит, и/files/{id}—FilesControllerберёт actor из cookie черезIActorAccessor),BoardMembers,BoardMyAccess(скрытые в ответ не попадают),SimilarTasks,Search. Списки фильтруютсяIProjectAccess.VisibleBoardIdsAsync(null — глобальный Admin+ или приватных проектов нет вовсе, и фильтра нет):TaskListFilter.VisibleBoardIds(и страница, и счётчики),SearchCriteria.VisibleBoardIds— вCommonFilterобеих половин поиска (чанки без проекта — люди — видны всем),FindSimilarAsync(…, visibleBoardIds)— внутри HNSW-подзапроса, иначе окно ближайших заполнили бы невидимые. В разборе строки поиска скрытый проект не резолвится ни попроект:KEY, ни по коду задачи — остаётся текстом. В приватном проекте нельзя снять или понизить последнего участника-администратора (400). Справочник людей общий. - Группы (этап 4C):
Group(Name ≤ 80, уникально без учёта регистра — хендлер, плюс unique-индекс),GroupMember(PK(GroupId, UserId), индекс UserId) иBoardGroup(PK(BoardId, GroupId),Role, индекс GroupId) — отдельные сущности, каскад от группы и от проекта; миграцияAddGroups. Роль участия вBoardAccessData.MemberRole— уже максимум из прямого участия и ролей групп человека (подзапросMAXв том же запросе, что и раньше;ProjectRoles.Effectiveо группах не знает), видимость приватного проекта — участие напрямую или через группу. Справочник групп —Features/Groups/GroupFeatures.cs: читают все, меняют глобальные Admin+ (EnsureCanManageGroups), добавляют только активных людей; роль группы в проекте —BoardGroupSet/Remove(ManageMembers, не выше своей), ответ —BoardMembersResponseсGroups(собираетIBoardMemberRepository.MembersResponseAsync). Правило «последний администратор приватного проекта» считает только прямых участников. API:/groups(GET/POST 201, PATCH/DELETE/groups/{id}, PUT/DELETE/groups/{id}/members/{userId}), PUT/DELETE/boards/{id}/groups/{groupId}({ role }). Клиент: вкладка «Люди → Группы» (Components/GroupsPanel), раздел «Группы» вProjectAccessDialog. - Команды (этап 4D): команда — группа с
Group.IsTeam(миграцияAddTeams), отдельной сущности нет.TaskItem.TeamId(FK → Groups SetNull, индекс) — сущность workspace, при переносе задачи сохраняется;TaskSetTeamCommand→ PATCH/tasks/{id}/team({ teamId }, правка задачи; не команда — 400, прежняя команда остаётся у задачи, но новой не назначается), журналTeamChanged. Системное поле экранаteam(Detail, после исполнителя; клиент прячет строку, пока команд нет). FQLteam = имя | myTeams() | EMPTY(IFqlLookup.TeamsAsync,FqlLookupпринимаетIGroupRepository). Клиент: флаг «Команда» в «Люди → Группы»,Components/TeamSelect+Services/TeamDirectory(один GET /groups на circuit, сброс после правок групп), дорожки «Команда» на канбане. - Наборы прав (этап 4E):
PermissionSet(Name ≤ 60 уникально — хендлер + unique-индекс,BaseRole— место на лестнице, не меняется;Permissions—int[], ViewProject всегда; миграцияAddPermissionSets). Встроенные наборы — сами роли (ProjectRoles.PermissionsOf), в коде, с постоянными IdPermissionSetRoles.BuiltInId(role), не правятся и не удаляются — свой делают клонированием.BoardMember.PermissionSetId/BoardGroup.PermissionSetId(FK SetNull): участие со своим набором хранитRole = BaseRole, поэтому «не выше своей», максимум ролей и «последний администратор» не изменились. Права —ProjectRoles.Resolve: роль прежняя (максимум), права — объединение источников: роль по умолчанию (в открытом проекте) и каждое участие (BoardAccessData.Grants: прямое и через группы, набор или права роли); наборы грузятся отдельным запросом только если участия на них ссылаются. Глобальные Admin+ — администраторы, как раньше.SetBoardMemberRequest(Role, PermissionSetId?)для участников и групп (встроенный Id — просто роль).Features/PermissionSets: список читают все, правит Owner (EnsureCanManagePermissionSets). API/permission-sets(GET, POST 201, PATCH/DELETE/{id}; встроенный — 400). Клиент: «Настройки → Наборы прав» (Components/PermissionSetsMatrix, Admin+ видит, Owner правит галочками, «Клонировать»), свои наборы вProjectRoleSelect(Sets/SetId/OnSetChange) в «Доступе» проекта, подписи прав —Permissions.PermissionLabel. - Тесты:
PermissionTests— по тесту на строку матриц,ProjectAccessTests— расчёт роли и таблица §2/§4 ТЗ проекта;TestMediatorFactoryиPostgresFixtureсеют Owner (OwnerId) — actor по умолчанию в остальных тестах. - Учётная запись живёт в Auth-модуле, профиль — в ядре; связь по одному Guid.
UserCreate(…, Password)сначала in-process создаёт учётную запись черезIAccountService.CreateAsync(user.Id, …), и только при успехе — профиль;UserChangeUsername/UserChangeEmail— сначала Auth-модуль, потом копия вUsers(нормализуют через домен, откатывают, проверяют, применяют — иначе Fake-репозиторий «находит» самого пользователя);UserDeactivate/UserActivate—DisableAsync/EnableAsyncдо смены статуса;UserChangePassword(UserId, CurrentPassword?, NewPassword)— только Auth-модуль. Отказ Identity по вводу →ArgumentException→ 400. - Уникальность username/email: локальная копия — быстрый 409 без вызова Auth-модуля (
UserCreateResult.IsUsernameTaken/IsEmailTaken,UserUpdateResult.ConflictError); источник истины — Identity (AccountResult.UsernameTaken/EmailTaken); плюс unique-индексы в БД. Features/Bootstrap/SeedBootstrapUserCommand— профиль базового пользователя с заданным Id (User.CreateWithId); учётную запись с тем же Id сеет Auth-модуль. Повтор — no-op. Оба сидера запускаются в одном хосте Flow.Api.UserGetMeQuery(ActorId)— профиль текущего actor (null → 401).- Все изменяющие команды пользователя возвращают общий
Features/Users/UserUpdateResult(NotFound | ConflictError | Success).UserUpdateProfile— PATCH-семантика: null не трогать, пустая строка очищает. - Назначение на задачу:
TaskItem.AssigneeId : Guid?,Assign/Unassign; назначать можно только активного пользователя — проверяетTaskAssignCommandHandler(TaskAssignResult: NotFound | ValidationError | Success).TaskListQuery(boardId, assigneeId?)фильтрует по исполнителю. IUserRepository.SearchAsync— автодополнение для@(ILIKE по Username/FirstName/LastName, только активные,%/_экранируются).GetByUsernamesAsync— резолв @упоминаний одним запросом (деактивированных не фильтрует).
Лента задачи: комментарии, журнал, срок (docs/TZ_task_activity_comments.md)
TaskActivity— append-only журнал:Type : TaskActivityType(Created, TitleChanged, DescriptionChanged, StatusChanged, AssigneeChanged, DueDateChanged, CommentAdded, CommentDeleted, AttachmentAdded/Removed, PriorityChanged, StartDateChanged, StoryPointsChanged, EstimateChanged, TypeChanged),OldValue/NewValueстроками (Guid статуса/пользователя/типа,yyyy-MM-ddдля дат, число enum'а для приоритета, invariant-число для оценок, текст для названия, null для описания). Создаётся только фабриками по типу (TaskActivity.StatusChanged(taskId, actorId, old, new)…), мутаций нет. Пишут хендлеры Application (TaskCreate→ Created;TaskUpdate— по записи на каждое реально измен
Truncated - read the full file at https://github.com/azizmac/Flow/blob/a0b0ee3564cd363971a7f0ed613167301ba002ac/AGENTS.md.
