diff --git a/docs/modal-acknowledgements.md b/docs/modal-acknowledgements.md index 0e456ee88..4778bc23c 100644 --- a/docs/modal-acknowledgements.md +++ b/docs/modal-acknowledgements.md @@ -35,3 +35,23 @@ Frontend не записывает эти состояния в `localStorage`. Дублирующее приветствие на главной странице офиса удалено. Единственный сценарий приветствия остаётся на detail-странице программы. + +## Кеш detail программы после подтверждения + +ProgramDetailResolver преобразует route `programId` в положительное целое число до +GetProgramUseCase. Для некорректного ID сохраняется прежний fallback `new Program()` +без HTTP-запроса и без нового окна/маршрута ошибки. + +ProgramRepository дополнительно нормализует ID в `getOne` и `acknowledgeWelcome` +одним helper: EntityCache всегда получает numeric key. Это исключает разные Map-ключи +`"7"` при чтении и `7` при инвалидации. Некорректные ID возвращают Observable error, +который существующие use cases преобразуют в Result failure; adapter не вызывается. + +Только успешный welcome POST инвалидирует detail-кеш конкретной программы. Другие +программы остаются в кеше; TTL 5 минут и архитектура EntityCache не изменены. Failed POST +не выставляет timestamp, не закрывает popup и не инвалидирует кеш; pending блокирует +повторный клик, после ошибки доступен retry. + +Server `welcomeAcknowledgedAt` остаётся source of truth: после success локальная программа +получает timestamp, popup закрывается; следующий вход выполняет свежий GET и не показывает +приветствие повторно. `localStorage`/`sessionStorage` для этого состояния не используются. diff --git a/docs/modules/invite.md b/docs/modules/invite.md index 1ddb86acb..7abf82a45 100644 --- a/docs/modules/invite.md +++ b/docs/modules/invite.md @@ -96,7 +96,36 @@ DI-биндинг (`infrastructure/di/invite.providers.ts`): | `GetMyInvitesUseCase` | — | `Result` | — | | `GetProjectInvitesUseCase` | `projectId` | `Result` | — | -Все ошибки коллапсятся в `{ kind: "...error", cause? }` (стандартный стиль). +Ошибки отправки `SendForUserUseCase` преобразуются централизованным +`mapInviteSendError` в `InviteSendError { kind, message }`, без raw cause в UI. +Остальные use cases сохраняют `{ kind: "...error", cause? }`. + +### Ошибки отправки и жизненный цикл формы + +Mapper в `api/invite/mappers/` распознаёт только известные ошибки `user` из +ожидаемых HTTP 400/422: `user_not_found`, `already_leader`, `already_member`, +`already_invited`, `not_program_participant`. Для 401/403, network/status 0, +5xx и неизвестных ошибок используются контролируемые `unauthorized`, `forbidden`, +`network`, `server`, `validation`, `unknown` сообщения. Сырые массивы, +английский DRF text и backend body никогда не отображаются. Ответ 500 с `"error"` +даёт «Не удалось отправить приглашение. Попробуйте ещё раз позже.» +Профильный invite facade использует те же typed kinds для существующих состояний, +без повторного разбора текста backend. + +`ProjectTeamService` очищает backend error, валидирует форму через +`ValidationService`, затем отправляет запрос. Invalid required/pattern не допускают +HTTP. Пока запрос выполняется, кнопка показывает loader и disabled; повторный +вызов также блокируется в facade. Видимость input block хранит `ProjectTeamUIService`. + +Ошибка завершает loading, но оставляет форму открытой и сохраняет ссылку/роль. +Одно конкретное сообщение выводится рядом со ссылкой в `role="alert"`. +Изменение link, role или specialization очищает backend error, не удаляя +frontend validators. Retry создаёт новый запрос. Только успех добавляет Invite, +очищает transient state и форму и скрывает input block. Reset сохраняет required/pattern. + +Backend rules, POST `/invites/`, payload и приглашения/участие в программе не меняются. +Ограничения размера команды, membership, submission freeze и дальнейшие изменения +invite lifecycle не входят в этот UI cleanup. --- diff --git a/docs/modules/program.md b/docs/modules/program.md index 0b55a7ad9..3387b0475 100644 --- a/docs/modules/program.md +++ b/docs/modules/program.md @@ -2,6 +2,57 @@ # Module: `program` +## Выбор системного кейса в проекте + +Production backend contract: `907ced23273e298314e9d8604dbaabb25ded6869`. +Системное поле определяется **только** точным `name="case"`, через общую константу +`PROGRAM_CASE_FIELD_NAME`. Это `PartnerProgramField`, не отдельная Case entity. + +При initial apply из программы поле case полностью исключается из +`programFieldValues`: не отправляется ни первый option, ни пустая строка/null. +Generic select сохраняет прежний первый placeholder, checkbox — `"false"`, +file по-прежнему исключается. Backend может создать draft без кейса. + +После apply навигация ведёт на `/office/projects/:projectId/edit` с query +`editingStep=additional`, `fromProgram=true`, `programLinkId` из ответа apply. +Дополнительные определения/значения далее загружаются canonical GET конкретной связи, +а не берутся из встроенного `partnerProgram.programFields/programFieldValues`. + +Для системного select используется обычный `app-select`, label поля и options backend, +но неизменный placeholder «Выберите кейс». Не выбранный кейс — пустой required control. +Draft save пропускает пустой/whitespace case в partial PUT; выбранный option сохраняется +и может быть изменён до сдачи. Пустое значение не является командой удалить ранее +сохранённый кейс. Повторное открытие восстанавливает canonical value; `fromProgram` +не подставляет первый option и не очищает уже сохранённый кейс. + +Сдача требует заполнения обязательных полей: PUT полей и POST submit используют один +проверенный `programLinkId`. Missing/stale case 400 показываются контролируемым текстом; +stale case обновляет canonical options, сохраняя остальные локальные правки. +При `submitted=true` поля read-only. Аналитика, фильтры и статистика по кейсам не меняются. + +### Canonical submission metadata / multi-program safety + +Canonical GET конкретного programLinkId содержит `isCompetitive`, `submissionOpen`, +`submissionDeadline: string | null`, `canSubmit` и `submitted`. Metadata backend +является authoritative context: frontend не рассчитывает сроки/доступность заново +и не использует legacy `project.partnerProgram.canSubmit` для submission decision. +Legacy singular link остаётся только fallback для ID, если валидный query ID отсутствует. + +Неконкурсная связь сохраняется без submit; сданная — без PUT и повторного submit. +Конкурсная несданная связь с `canSubmit=true` проходит validation → confirmation → +PUT fields → POST submit → обычное сохранение Project. При закрытой подаче показывается +существующая deadline modal, без PUT/POST и без перехода в обычный publish. + +Backend submit остаётся последней authoritative validation при гонках: известные 400 +мапятся в `submission_closed`, `already_submitted`, `not_competitive`. Первый открывает +deadline UI; последние два обновляют canonical GET без автоматического повторного submit +или success. Если обновлённый snapshot сдан, controls становятся read-only. + +Обычное сохранение в verified canonical context исключает `partnerProgramId` из копии +Project payload: поле иначе означало бы legacy bind/unbind command и могло удалить +другую связь проекта. Сам FormControl не меняется; explicit dedicated assignment и +обычные legacy flows вне canonical context сохранены. + Партнёрские программы — крупные мероприятия с проектами, участниками, сроками регистрации, оценкой проектов экспертами. Связан с [`project`](project.md) (проект подаётся в программу), [`courses`](courses.md) (программа может иметь привязанный курс), [`news`](news.md) (программа имеет свою ленту новостей). ## Назначение @@ -58,6 +109,11 @@ export class Program { // Роли пользователя в программе isUserManager: boolean; // организатор isUserMember: boolean; // участник + currentProjectApplication: { + projectId: number; + programLinkId: number; + submitted: boolean; + } | null; // Связь с курсом (опционально) publishProjectsAfterFinish: boolean; @@ -270,12 +326,341 @@ DI-биндинги (`infrastructure/di/program/`): ## Consumers -| Где | Как использует | -| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `pages/projects/edit/components/project-additional-step` | Использует `ProgramRepositoryPort.getProgramProjectAdditionalFields()` — определяет дополнительные поля проекта в программе. | -| `pages/projects/detail/info` | `Project.partnerProgram` — связь с программой. | -| `pages/courses/detail/...` | `course.partnerProgramId` — связь курса с программой. | -| `widgets/detail` | `listType: "program"` режим. | -| `domain/auth/user.model.ts` | `User.programs: Program[]` — программы пользователя. | +| Где | Как использует | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `pages/projects/edit/components/project-additional-step` | Использует canonical GET/PUT через `ProjectProgramRepositoryPort` для активного `programLinkId`. | +| `pages/projects/detail/info` | `Project.partnerProgram` — связь с программой. | +| `pages/courses/detail/...` | `course.partnerProgramId` — связь курса с программой. | +| `widgets/detail` | `listType: "program"` режим. | +| `domain/auth/user.model.ts` | `User.programs: Program[]` — программы пользователя. | --- + +## Детализация экспертного оценивания в аналитике + +На `/office/program/:programId/analytics` организатор открывает назначения из трёх +метрик: «Назначений», «Выполнено», «Ожидает». Нулевые метрики остаются видимыми, +но неактивными. Доступ окончательно проверяет backend. Новых маршрутов нет. + +### API и архитектура + +- `GET /programs/:programId/project-analytics/` — сводка, включая `attention.delayedExperts`. +- `GET /programs/:programId/project-analytics/assignments/?scope=all|completed|pending` — массив назначений. +- `GET /programs/:programId/project-analytics/assignments/:assignmentId/scores/` — поля назначения и все критерии в `scores`. + +Backend API в этом PR не изменялся. Данные проходят через существующий +`CamelcaseInterceptor`: `assignment_id → assignmentId`, `criteria_total → criteriaTotal`, +`criteria_scored → criteriaScored`, `waiting_seconds → waitingSeconds`, +`delayed_experts → delayedExperts`. Важно: установленный camelcase-keys преобразует +`overdue_24h → overdue24H` и `overdue_48h → overdue48H` (заглавная H). +Ручного преобразования SLA-полей нет. + +Путь данных: `ProgramRepositoryPort → ProgramHttpAdapter / ProgramRepository → +GetProgramManagerAssignmentsUseCase / GetProgramManagerAssignmentScoresUseCase → +ProgramAnalyticsDrilldownService → AnalyticsDrilldownComponent`. +Use cases возвращают `Result` с `ProgramAnalyticsError`; сырой текст HTTP-ошибки не попадает в UI. + +### Назначения, статусы и оценки + +| Scope | Заголовок | Содержимое | +| ----------- | ------------------------ | ------------------------------------- | +| `all` | Все назначения экспертов | Все физически существующие назначения | +| `completed` | Выполненные назначения | Завершённое оценивание | +| `pending` | Ожидают оценки | `not_ready`, `pending`, `in_progress` | + +Статусы backend отображаются без пересчёта: + +- `not_ready` — «Проект не сдан»; +- `pending` — «Не начал оценивание»; +- `in_progress` — «В процессе»; +- `completed` — «Выполнено». + +Прогресс — например, «2 из 5 критериев»; при отсутствии критериев — «Нет критериев», +для несданного проекта — «—». В open-режиме показываются реальные назначения, +но frontend не синтезирует задержки экспертов. В distributed-режиме +«Частично оценено» означает, что хотя бы один назначенный эксперт полностью оценил +проект, но не все назначенные эксперты завершили оценивание. + +«Посмотреть оценку» открывает detail внутри той же модалки. Возврат к списку +не перезагружает назначения. Отображаются все критерии и их описания; диапазон +min/max показан отдельно от значения. Булевы строки True/False — «Да/Нет», +числа и текст — как присланы backend. `isScored=false` — «Не оценено»; +существующая запись с `value=null` или пустой строкой — «Пустое значение». +Общий балл, среднее и итоговый рейтинг не вычисляются. + +### Задержки и backlog + +Третья метрика «Требует внимания» использует `attention.delayedExperts.total`. +При всех трёх нулевых значениях сохраняется зелёный empty state. +Список экспертов использует backend order, счётчики и severity: +`warning` — «Требует внимания», `critical` — «Критическая задержка». +Backend SLA: минимум два ожидания 24 часа либо одно 48 часов; UI не проверяет пороги. + +При открытии списка задержек один раз запрашивается `scope=pending` для backlog. +Ошибка этого запроса не скрывает экспертов из overview. Backlog фильтруется строго +по `assignment.expert.expertId`, а не по userId или имени. `not_ready` отделён +в нижнюю секцию «Ещё не сданы»; порядок внутри групп сохранён. + +Ожидание форматируется только из authoritative `waitingSeconds`: +менее часа — «< 1 ч», 7 часов — «7 ч», 30 часов — «1 д 6 ч», +52 часа — «2 д 4 ч». Таймера и вычисления SLA по датам нет. +При `null`: несданный проект — «Проект не сдан», выполненное назначение — «—», +остальные — «Нет данных». Неизвестная дата не подменяется нулевым ожиданием. + +### Состояние, ошибки и доступность + +Facade создаётся для одной модалки. До пользовательского действия запросов нет; +повторное открытие загружает свежие данные. Закрытие, смена программы и destroy +отменяют запросы и очищают detail/selected expert. Поздние ответы не обновляют +текущую программу. Ошибки 401, 403, 404 и сети показываются локально с retry, +не закрывая модалку и не скрывая основную аналитику. + +Используется неизменённый shared `app-modal`. Analytics-specific компонент получает +его public `overlayRef` через ViewChild после `ngAfterViewInit` и подписывается +на attachments/detachments/keydownEvents с `takeUntilDestroyed`. +`openChange` обслуживает только backdrop, не lifecycle overlay. + +Один `role=dialog`, `aria-modal=true`, динамический `aria-labelledby` и один CDK +focus trap охватывают list/detail/delayed/backlog. Trap расположен внутри +проецируемого dialog-wrapper, поэтому его anchors переносятся вместе с содержимым +при повторном открытии portal. Initial focus на close button выставляется только +после attachment. Переходы сохраняют trap и переводят focus +на heading через Angular render lifecycle. AutoCapture/automatic restore CDK выключены. + +Escape через overlay keyboard events закрывает всю модалку, не выполняет Back. +Backdrop, Escape и close button используют `closeAnalyticsModal()`. +Возврат фокуса происходит на detach только на сохранённый HTMLElement-trigger, +если он `isConnected`. Смена программы/destroy очищают ссылку. Учитывается +bottom-up destroy Angular: дочерний modal может detach до cleanup родителя; +проверяется DestroyRef владельца view, без изменения shared primitive. +Таймеров, polling, MutationObserver и document-global Escape handler нет. + +Desktop: таблица внутри модалки шириной до 880px. Mobile/tablet: stacked cards, +перенос длинных имён и названий, ограничение высоты с вертикальным скроллом. +Сохранены Mont, токены и существующие zero states аналитики. + +### Проверка + +Regression tests покрывают adapter/repository/use cases/facade/interceptor, +scope/status/progress, nullable ожидание, критерии, задержки, смену программы, +отмену запросов и настоящий CDK Overlay lifecycle. Focus tests не подменяют +attachment событием openChange и не добавляют ручной detectChanges после клика. + +Ручной smoke перед production release: все три scope, detail/back, +delayed/backlog/back, loading/error/retry, длинные имена на desktop/mobile, +Tab/Shift+Tab, Escape, backdrop, возврат на конкретный trigger и смена программы. +Backend, React, shared modal, зависимости, workflows/Docker и deploy вне изменений. + +## Детализация «Требует внимания»: участники и работы (v1) + +Контракт доступен в production backend `907ced23273e298314e9d8604dbaabb25ded6869`. +Два дополнительных root-view расширяют существующий `AnalyticsDrilldownComponent`: +`participants-without-team` и `projects-awaiting-evaluation`. Все три строки внимания +теперь кнопки; нулевая строка неактивна, а при всех нулях сохранено «Ничего не требует внимания». +Tooltip — соседний контрол, его нажатие не открывает детализацию. + +### API и типы + +- `getManagerParticipantsWithoutTeam` / `GetProgramManagerParticipantsWithoutTeamUseCase`: + `GET /programs/:programId/project-analytics/participants-without-team/`. +- `getManagerProjectsAwaitingEvaluation` / `GetProgramManagerProjectsAwaitingEvaluationUseCase`: + `GET /programs/:programId/project-analytics/projects-awaiting-evaluation/`. + +Оба метода принимают `ProgramAnalyticsAttentionQuery` (`search`, `limit`, `offset`). +Путь данных сохраняет port → HTTP adapter → repository → use case → page-local facade. +Компонент не использует HttpClient. Use cases возвращают `Result<…, ProgramAnalyticsError>`. +Тип страницы переиспользует `ApiPagination`, уточняя nullable `next`/`previous` только +в analytics contract; общая модель пагинации не меняется. Ручного snake_case parser нет. +Общий interceptor преобразует `user_id`, `registered_at`, `program_project_id`, +`submitted_at`, `reason_label`, `assignments_total`, `assignments_completed` в camelCase. + +`ProgramAnalyticsAttentionParticipant` содержит только `userId`, `fullName`, `avatar`, +`city`, `registeredAt`. Дата означает регистрацию в программе, не создание аккаунта. +Legacy city показывается как прислано. Отсутствующие город/дата: «Не указано» / «Нет данных». +Отсутствие команды не означает заявленный статус «Ищет команду». + +`ProgramAnalyticsAttentionProject` — одна сданная работа программы: `programProjectId`, +`project`, nullable `leader`/`submittedAt`, `status`, `reason`, `reasonLabel` и nullable +assignment-счётчики. Режим берётся из `mode` ответа списка, не из старого overview. +Пустой руководитель — «Не указан», неизвестная дата — «Дата сдачи неизвестна». + +Причины backend (код используется как стабильный идентификатор, label — для отображения): + +- `no_assignments` — «Эксперты не назначены»; +- `no_completed_evaluations` — «Нет завершённых оценок»; +- `partially_evaluated` — «Частично оценено»; +- `awaiting_first_evaluation` — «Ожидает первой оценки» (open). + +В distributed прогресс: «Завершили: X из Y» из `assignmentsCompleted/assignmentsTotal`; +при total=0 — «Нет назначений». В open — «—», null не становится нулём. +Нет расчёта процентов, SLA, средних баллов или требуемого числа экспертов. +Несданные работы исключены backend и не добавляются обратно через assignments. + +### Поиск, страницы, ошибки и отмена + +Данные не кешируются между открытиями. Нажатие root-trigger очищает прошлый контекст, +поиск и offset и делает один запрос. Вместе с overview списки не загружаются. +Черновик поиска применяется по Enter/«Найти», сервер получает trimmed search. +«Очистить» возвращает полный список. Новый поиск всегда начинает с offset=0. +Размер страницы фиксирован: 25. «Назад»/«Далее» меняют offset, диапазон и count +отображаются из актуального detail response. Ссылки backend next/previous не открываются +напрямую. Расхождение со старым числом overview не ошибка; overview циклически не обновляется. + +Loading, успешный список, empty без поиска и search-empty — разные состояния. +При пустом поиске: «Все зарегистрированные участники уже состоят в командах.» либо +«Нет работ, ожидающих оценивания.» При применённом поиске: «По вашему запросу ничего не найдено.» +Ошибки 401/403/404/network локальны и не скрывают основную аналитику. Показываются +только контролируемые сообщения, не HTTP body. Retry сохраняет view/program/search/limit/offset. + +`cancelAttention` + `takeUntilDestroyed` отписывают активный запрос при закрытии, +другом root-open, новом поиске, смене страницы/программы и destroy. Поздний ответ +не может перезаписать новую страницу. Перед загрузкой старые results очищаются. +Сохраняется реактивный parent `ActivatedRoute.paramMap` из #332: смена 12 → 13 сразу +обнуляет публичный programId, отменяет overview и через signal input закрывает drilldown. +До соответствующего resolver Program(13) новый overview не запрашивается; новый attention +список не запрашивается и после resolver, пока пользователь снова не нажмёт строку. + +### Общая модалка, навигация и ограничения + +Shared `app-modal` не меняется. У всех views один overlay/dialog/focus trap. +Focus на close только после attachments, Escape через overlay keydownEvents, +backdrop и close используют единый flow; при detach возврат на конкретный connected trigger. +Смена программы/destroy не возвращают focus в старый контекст. Сохранён bottom-up cleanup. +Новых таймеров, polling, глобальных keyboard handlers или второго trap нет. + +Явные RouterLink-действия: «Открыть профиль» → `/office/profile/:userId`, +«Открыть проект» → `/office/projects/:projectId`. При уходе ссылка-trigger очищается, +модалка закрывается, запросы отменяются. Новых маршрутов и вложенных modal нет. +На desktop — таблицы; mobile/tablet — stacked cards с подписями, переносом длинных +значений и вертикальным скроллом внутри существующего dialog до 880px шириной. + +V1 не добавляет вуз/роль/кейс, contact/team actions, напоминания, назначения, +несданные работы, exports или fake data. Контракт/семантика backend не расширяются. +Targeted tests покрывают pipeline, camelcase/null, поиск/страницы/retry/races, +реальный overlay lifecycle и observable route change до resolver. +Ручной production smoke и mobile/keyboard проверяются отдельно от unit/component tests. + +## Детализация «Проекты не сдали решение» + +Production backend contract: `907ced23273e298314e9d8604dbaabb25ded6869`. + +### Единицы воронок + +Строка «Сдали проект» удалена только из `participantFunnel` computed UI. +`participantFunnel.submittedProjectCreators` остаётся частью domain/API contract: +это уникальные зарегистрированные руководители, а не число сданных проектов. +Два руководителя могут сдать шесть проектов. Воронка участников содержит три стадии: +«Зарегистрировались» (`uniqueParticipants`), «В команде» (`withTeam`), +«Создали проект» (`projectCreators`). Техническое поле `registrations` сохранено в +domain/API contract, но не отображается отдельной метрикой. Для данных 11 / 7 / 4 / 3 +пользователь видит 7 → 4 → 3. Нулевые пользовательские метрики сохраняют zero state, +даже если техническое количество регистрационных записей ненулевое. +Tooltip первой стадии: «Уникальные зарегистрированные участники программы.» +Tooltip карточки: «Путь участников от регистрации до создания проекта.» +Сдача относится к воронке проектов. +Её стадии и значения не меняются: «Создано», «Не сдано», «Сдано», +«Оценено». «Сдано» по-прежнему использует `solutionFunnel.submitted`. + +Подписи evaluation, assignment и attention metrics используют существующий +`text-body-10` (10px), как подписи воронки. Текст «Максимум экспертов на проект» +также 10px. Конфликтующие scoped font-size удалены; глобальная типографика, +размеры заголовков и акцентных числовых значений не меняются. Число лимита сохраняет 13px. + +### Применимость и путь данных + +Overview `attention.projectsNotSubmitted: { applicable: boolean; total: number }` +задаёт применимость. При `applicable=false` строки нет и detail не запрашивается, +даже если старый `solutionFunnel.notSubmitted` ненулевой. При true строка +«Проекты не сдали решение» стоит после работ, ожидающих оценивания, перед экспертами. +Нулевой total виден неактивной кнопкой; сообщение «Ничего не требует внимания» +сохраняется при всех нулевых метриках. Tooltip — отдельный соседний контрол: +«Проекты конкурсной программы, которые ещё не отправили решение.» + +`GetProgramManagerProjectsNotSubmittedUseCase` вызывает repository port → repository +→ HTTP adapter → `GET /programs/:programId/project-analytics/projects-not-submitted/`. +Компоненты не используют HttpClient. `ProgramAnalyticsNotSubmittedProjectsPage` +расширяет существующий `ProgramAnalyticsAttentionPage`. +Новые общие pagination-модели и manual snake_case преобразования не добавлены. +`CamelcaseInterceptor` преобразует `projects_not_submitted`, `program_project_id`, +`user_id`, `full_name`, `linked_at`, `submission_deadline`, `submission_open`. + +Строка содержит `programProjectId`, безопасные `project` и nullable `leader`, +`linkedAt` — дату связи с программой, не создания проекта. Unknown leader — +«Не указан», пустой avatar использует существующий placeholder. Никаких email, +кейсов, предполагаемых процентов готовности или дополнительных пользовательских +запросов нет. + +### View, сроки и страницы + +Новый root-view `projects-not-submitted` расширяет ту же модалку, overlay, dialog +и единственный CdkTrapFocus. Desktop — таблица, mobile/tablet — stacked rows с +подписями; длинные имена переносятся. Колонки: проект, руководитель, «В программе с» +(`dd.MM.yyyy`), статус, «Открыть проект» (`RouterLink /office/projects/:projectId`). + +Metadata берётся из detail, не из потенциально устаревшего overview: + +- известный `submissionDeadline` и `submissionOpen=true`: «Сдача открыта до …»; +- известный срок и false: «Срок сдачи завершён …»; +- null: «Срок сдачи не указан». + +Дата отображается DatePipe в локальном часовом поясе браузера как +`dd.MM.yyyy, HH:mm`. Статус строки — «Не сдано» или «Срок сдачи завершён» строго +по backend `submissionOpen`, не по Date.now(). Клиент не рассчитывает SLA, +оставшиеся часы или severity. Если detail сообщает `applicable=false`, UI +показывает «Для этой программы сдача решения не требуется.», не утверждает, +что все проекты сданы. + +Поиск по названию отправляется только Enter/«Найти». «Очистить» сбрасывает search +и offset; limit=25. Диапазон, count и «Назад»/«Далее» берутся из detail. По next URL +клиент не переходит. Retry сохраняет program/search/offset. Loading: «Загружаем список…»; +empty: «Все проекты программы уже сданы.»; search-empty: «По вашему запросу ничего +не найдено.» Ошибки 401/403/404/network контролируемые, raw HTTP body не выводится +и основная аналитика не скрывается. + +### Отмена, доступность и проверки + +Повторно используется `cancelAttention`/`takeUntilDestroyed`: close, другой root, +search, page, смена программы, destroy и RouterLink отменяют запрос и очищают +старую страницу. Нет второго механизма race-control. Observable parent paramMap +12 → 13 обнуляет exposed programId до resolver, закрывает модалку, отписывает +старый Subject. Program(13) запускает только overview; detail ждёт нового клика. + +Сохранены initial focus только после attachments, Escape/backdrop/close через +единый flow, возврат на connected trigger, отсутствие возврата при RouterLink, +смене программы, disconnected trigger и bottom-up destroy. Новый view включён +в parameterized lifecycle, search/pagination/race и SPA regression tests. + +V1 не меняет backend, React, shared modal, submission/scoring lifecycle, зависимости, +workflows, Docker или deploy. Unit/component tests с реальным CDK Overlay не +заменяют отдельную браузерную desktop/mobile/keyboard/E2E проверку. + +## Кейсы в project analytics + +Карточка «Кейсы» напрямую использует обязательный `ProgramAnalyticsOverview.cases` +из того же `GET /programs/:programId/project-analytics/`, без дополнительного запроса. +Production backend contract: `907ced23273e298314e9d8604dbaabb25ded6869`. +Global CamelcaseInterceptor преобразует `submission_applicable`, `without_case`, +`participants_total`, `projects_total`, `not_submitted` в соответствующие camelCase-поля. +Ручного парсера и пересчёта backend counts нет; вычисляются только проценты ширины полосы. + +- `configured=false`: «Кейсы не настроены для этой программы», без строк или withoutCase. +- Backend order `items` сохраняется, в том числе все настроенные варианты с нулевыми + счётчиками. Для `configured=true, items=[]` — controlled empty state + «Для программы пока не настроены варианты кейсов». +- `withoutCase` добавляется последней строкой «Без выбранного кейса» только при + `projectsTotal > 0` и наличии настроенных вариантов. Legacy/missing choices не скрываются. +- При `submissionApplicable=false` видны только название, проекты и участники: + submitted/notSubmitted остаются в модели, но их подписи и segmented bar скрыты. +- Participants уникальны внутри каждого кейса, но могут повторяться в разных кейсах. + Их нельзя складывать в global unique. Это поясняет tooltip карточки. +- Полоса показывает submitted/projectsTotal и notSubmitted/projectsTotal; при нуле + проектов обе ширины равны нулю. Доступное текстовое описание содержит все метрики; + строки не кликабельны, без drilldown/export/filter/sorting. + +Локальная типографика: заголовок 16/22, 600; имя 12/18, 600 (две строки, полный текст +в DOM); главное число 17/20, 700; служебные подписи и empty state 10px. +Global tokens и размеры других карточек не меняются. Название и total разделены +grid `minmax(0, 1fr) auto`; метрики переносятся на узкой ширине. +Карточка сохраняет min-height 250px и ограничена max-height 380px; внутренний список +имеет bounded vertical scroll при большом числе кейсов и доступен с клавиатуры. +На mobile карточка занимает ширину существующей сетки, без отдельного компонента. diff --git a/docs/modules/project.md b/docs/modules/project.md index a2a129961..1f2f030f8 100644 --- a/docs/modules/project.md +++ b/docs/modules/project.md @@ -2,6 +2,65 @@ # Module: `project` +## Canonical additional-fields edit flow + +Активная связь определяется в `ProjectAdditionalService`: валидный query +`programLinkId` имеет приоритет, затем используется `project.partnerProgram.programLinkId` +для старых ссылок. ProjectId/programId/первый program tag не используются как link ID. + +- GET `/programs/partner-program-projects/:programLinkId/fields/` возвращает + `ProgramLinkFields`: projectId, programId, programLinkId, submitted, fields с value, + isCompetitive, submissionOpen, submissionDeadline (string|null), canSubmit. +- PUT того же URL отправляет массив `{ field_id, value_text }` и получает подтверждение, + не Project. `GetProgramLinkFieldsUseCase` / `UpdateProgramLinkFieldsUseCase` возвращают Result. +- POST `/programs/partner-program-projects/:programLinkId/submit/` использует ту же связь, + только после успешного PUT. Существующие правила доступности сдачи не расширяются. + +Production backend contract SHA: `907ced23273e298314e9d8604dbaabb25ded6869`. +Все решения о сдаче и её доступности используют canonical metadata активной связи, +не legacy `project.partnerProgram`. При смене context ID и metadata сбрасываются вместе; +metadata старого ответа не может заменить данные новой связи. Даты/флаги не пересчитываются +на frontend. Закрытая конкурсная связь не превращается в обычный publish: deadline UI +показывается до отправки fields/submit. Неконкурсная связь не вызывает submit, сданная +не вызывает ни fields PUT, ни повторный submit. + +Для ordinary edit/save с verified activeProgramLinkId `UpdateFormUseCase` получает +новую копию payload **без partnerProgramId** (а значит, без partner_program_id после +interceptor). Это исключает случайную команду изменить Project × Program bindings A/B. +FormControl сохраняется; `assignProjectToProgram()` и dedicated `AssignProjectProgramUseCase` +не меняются. Вне canonical context прежняя payload semantics, включая explicit legacy +bind/unbind, сохранена. + +`CamelcaseInterceptor` сохраняется; repository преобразует GET DTO в domain snapshot. +До применения проверяются projectId текущего route и programLinkId запроса. +Несоответствие логируется, показывает controlled loading error и блокирует PUT. +Реактивные route params/query и resolver data отменяют старые запросы; новый GET +не выполняется до совпадения resolved Project с route projectId. + +Один экземпляр additionalForm сохраняется на протяжении жизни редактора. +Loading, error с «Повторить», empty success и заполненный success различаются. +Ошибка GET: «Не удалось загрузить дополнительные сведения программы.» +Неизвестные PUT ошибки, network и 5xx отображаются только через заданные сообщения, +без raw backend body и без потери введённых значений. Case errors подсвечивают control; +при устаревшем option повторный GET получает актуальные options без смешивания программ. +Отдельные controlled kinds `submission_closed`, `already_submitted`, `not_competitive` +обрабатывают backend races после GET. Deadline failure открывает существующую late modal; +already-submitted/noncompetitive повторяют canonical GET и не сообщают success. +Backend submit validation и endpoint не меняются и остаются final authority. + +System case определяется exact `name="case"`. Нулевое value становится `""`, +checkbox/radio — false; сохранённые значения восстанавливаются. Кейс имеет required +validation, но draft save опускает пустой case (partial update). Publish/submit +блокируется до выбора case; нет auto-select первого варианта. `submitted=true` +отключает controls, запрещает PUT и показывает «Проект уже сдан. Дополнительные +сведения изменить нельзя.» Основные сведения проекта сохраняются независимо от +disabled additionalForm, без повторной отправки frozen fields. + +Новый edit flow больше не использует `/projects/:projectId/program-fields/`. +Legacy embedded fields остаются в глобальной Project model ради совместимости, +но не являются authoritative source этой формы. Backend, team/invite rules, +состав команды, кейс-аналитика и API contracts не меняются. + Самый большой модуль приложения — проекты. Состоит из 8 sub-доменов на уровне портов и репозиториев: - `project` — основная CRUD-логика проекта. @@ -80,7 +139,7 @@ interface UpdateFormCommand { | `ProjectGoalsRepositoryPort` | `fetchAll(projectId)`, `createGoal(projectId, params: GoalFormData[])`, `editGoal(projectId, goalId, params)`, `deleteGoal(projectId, goalId)` | | `PROJECT_NEWS_REPOSITORY` | `NewsRepositoryPort`: `fetchNews`, `fetchNewsDetail`, `addNews`, `readNews`, `delete`, `toggleLike`, `editNews` | | `ProjectPartnerRepositoryPort` | `fetchAll`, `createPartner`, `updatePartner` (только `contribution` + `decisionMaker`), `deletePartner` | -| `ProjectProgramRepositoryPort` | `assignProjectToProgram(projectId, partnerProgramId)`, `sendNewProjectFieldsValues(projectId, newValues)` | +| `ProjectProgramRepositoryPort` | `assignProjectToProgram(projectId, partnerProgramId)`, `getProgramLinkFields(programLinkId)`, `updateProgramLinkFields(programLinkId, values)` | | `ProjectRatingRepositoryPort` | `getAll(programId, params?)`, `postFilters(programId, filters, params?)`, `rate(projectId, scores)`, `formValuesToDTO(criteria, outputVals)` | | `ProjectResourceRepositoryPort` | `fetchAll`, `createResource`, `updateResource`, `deleteResource` | | `ProjectSubscriptionRepositoryPort` | `getSubscribers`, `addSubscription`, `getSubscriptions(userId, params?)`, `deleteSubscription` | @@ -113,7 +172,7 @@ project-subscription.providers.ts | `LeaveProjectUseCase` | Выйти из проекта. | | `RemoveProjectCollaboratorUseCase` | Удалить участника (эмитит `RemoveProjectCollaborator`). | | `SubmitCompetitiveProjectUseCase` | Подать проект на конкурс / партнёрскую программу. | -| `SendProjectAdditionalFieldsUseCase` | Отправить дополнительные поля для партнёрской программы. | +| `GetProgramLinkFieldsUseCase` / `UpdateProgramLinkFieldsUseCase` | Загрузить/частично обновить поля конкретной связи проекта с программой. | | `GetProjectGoalsUseCase`, `CreateGoalsUseCase`, `DeleteGoalUseCase` | CRUD по целям. | | `GetProjectPartnersUseCase`, `CreatePartnerUseCase`, `DeletePartnerUseCase` | CRUD по партнёрам. | | `GetProjectResourcesUseCase`, `CreateResourceUseCase`, `DeleteResourceUseCase` | CRUD по ресурсам. | @@ -221,16 +280,16 @@ DTO лежат в `dto/`: HTTP-эндпоинты (укрупнённо): -| Адаптер | Базовый URL | Ключевые операции | -| --------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `ProjectHttpAdapter` | `/projects` | `getAll`, `getOne`, `getMy`, `postOne` (создаёт черновик), `update(id, data)`, `deleteOne(id)`, `getCount` | -| `ProjectCollaboratorsHttpAdapter` | `/projects//collaborators` | DELETE collaborator, PATCH switch_leader, DELETE leave | -| `ProjectGoalsHttpAdapter` | `/projects//goals` | CRUD | -| `ProjectNewsHttpAdapter` | `/projects//news` | CRUD + set_viewed + set_liked | -| `ProjectPartnerHttpAdapter` | `/projects//partners` | CRUD | -| `ProjectProgramHttpAdapter` | `/projects//...` (assign-to-program) и `/projects//program-fields/` | assign + send-fields | -| `ProjectRatingHttpAdapter` | `/programs//projects` | get rating list, post filters, rate | -| `ProjectResourceHttpAdapter` | `/projects//resources` | CRUD | +| Адаптер | Базовый URL | Ключевые операции | +| --------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `ProjectHttpAdapter` | `/projects` | `getAll`, `getOne`, `getMy`, `postOne` (создаёт черновик), `update(id, data)`, `deleteOne(id)`, `getCount` | +| `ProjectCollaboratorsHttpAdapter` | `/projects//collaborators` | DELETE collaborator, PATCH switch_leader, DELETE leave | +| `ProjectGoalsHttpAdapter` | `/projects//goals` | CRUD | +| `ProjectNewsHttpAdapter` | `/projects//news` | CRUD + set_viewed + set_liked | +| `ProjectPartnerHttpAdapter` | `/projects//partners` | CRUD | +| `ProjectProgramHttpAdapter` | `/projects/assign-to-program/`, `/programs/partner-program-projects//fields/` | assign + canonical GET/PUT fields | +| `ProjectRatingHttpAdapter` | `/programs//projects` | get rating list, post filters, rate | +| `ProjectResourceHttpAdapter` | `/projects//resources` | CRUD | Subscription-репозиторий пользуется `ProjectHttpAdapter` (без отдельного адаптера). diff --git a/package-lock.json b/package-lock.json index c8ae93ecf..5c7c50643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "rxjs": "~7.5.0", "snakecase-keys": "^5.4.2", "tslib": "^2.3.0", + "write-excel-file": "4.1.1", "zone.js": "^0.15.1" }, "devDependencies": { @@ -14382,6 +14383,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -27541,6 +27548,18 @@ "dev": true, "license": "ISC" }, + "node_modules/write-excel-file": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/write-excel-file/-/write-excel-file-4.1.1.tgz", + "integrity": "sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/write-file-atomic": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", diff --git a/package.json b/package.json index f9d974907..d93fe22ad 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "rxjs": "~7.5.0", "snakecase-keys": "^5.4.2", "tslib": "^2.3.0", + "write-excel-file": "4.1.1", "zone.js": "^0.15.1" }, "devDependencies": { diff --git a/projects/core/src/consts/lists/russian-regions-list.const.spec.ts b/projects/core/src/consts/lists/russian-regions-list.const.spec.ts new file mode 100644 index 000000000..4d5d885b0 --- /dev/null +++ b/projects/core/src/consts/lists/russian-regions-list.const.spec.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { + filterRussianRegions, + findCanonicalRussianRegion, + russianRegions, +} from "./russian-regions-list.const"; + +describe("russianRegions", () => { + it("contains a unique canonical list", () => { + expect(russianRegions).toHaveLength(89); + expect(new Set(russianRegions).size).toBe(russianRegions.length); + }); + + it.each([ + ["Москва", "Москва"], + [" мОскВа ", "Москва"], + ["санкт-петербург", "Санкт-Петербург"], + ])("normalizes safe case and whitespace differences for %s", (value, expected) => { + expect(findCanonicalRussianRegion(value)).toBe(expected); + }); + + it("does not guess misspelled or arbitrary legacy values", () => { + expect(findCanonicalRussianRegion("Миксва")).toBeNull(); + expect(findCanonicalRussianRegion("Россия")).toBeNull(); + }); + + it("filters by a case-insensitive substring", () => { + expect(filterRussianRegions(" татар ")).toEqual(["Республика Татарстан"]); + }); + + it("ranks exact, prefix and substring matches deterministically", () => { + expect(filterRussianRegions("моск").slice(0, 2)).toEqual(["Москва", "Московская область"]); + expect(filterRussianRegions("Москов")).toEqual(["Московская область"]); + expect(filterRussianRegions("Москва")).toEqual(["Москва"]); + }); +}); diff --git a/projects/core/src/consts/lists/russian-regions-list.const.ts b/projects/core/src/consts/lists/russian-regions-list.const.ts new file mode 100644 index 000000000..8a3611c74 --- /dev/null +++ b/projects/core/src/consts/lists/russian-regions-list.const.ts @@ -0,0 +1,134 @@ +/** + * Канонические display-названия субъектов РФ для формы проекта. + * + * @format + */ + +export const russianRegions = [ + "Республика Адыгея", + "Республика Алтай", + "Республика Башкортостан", + "Республика Бурятия", + "Республика Дагестан", + "Донецкая Народная Республика", + "Республика Ингушетия", + "Кабардино-Балкарская Республика", + "Республика Калмыкия", + "Карачаево-Черкесская Республика", + "Республика Карелия", + "Республика Коми", + "Республика Крым", + "Луганская Народная Республика", + "Республика Марий Эл", + "Республика Мордовия", + "Республика Саха (Якутия)", + "Республика Северная Осетия — Алания", + "Республика Татарстан", + "Республика Тыва", + "Удмуртская Республика", + "Республика Хакасия", + "Чеченская Республика", + "Чувашская Республика", + "Алтайский край", + "Забайкальский край", + "Камчатский край", + "Краснодарский край", + "Красноярский край", + "Пермский край", + "Приморский край", + "Ставропольский край", + "Хабаровский край", + "Амурская область", + "Архангельская область", + "Астраханская область", + "Белгородская область", + "Брянская область", + "Владимирская область", + "Волгоградская область", + "Вологодская область", + "Воронежская область", + "Запорожская область", + "Ивановская область", + "Иркутская область", + "Калининградская область", + "Калужская область", + "Кемеровская область — Кузбасс", + "Кировская область", + "Костромская область", + "Курганская область", + "Курская область", + "Ленинградская область", + "Липецкая область", + "Магаданская область", + "Московская область", + "Мурманская область", + "Нижегородская область", + "Новгородская область", + "Новосибирская область", + "Омская область", + "Оренбургская область", + "Орловская область", + "Пензенская область", + "Псковская область", + "Ростовская область", + "Рязанская область", + "Самарская область", + "Саратовская область", + "Сахалинская область", + "Свердловская область", + "Смоленская область", + "Тамбовская область", + "Тверская область", + "Томская область", + "Тульская область", + "Тюменская область", + "Ульяновская область", + "Херсонская область", + "Челябинская область", + "Ярославская область", + "Москва", + "Санкт-Петербург", + "Севастополь", + "Еврейская автономная область", + "Ненецкий автономный округ", + "Ханты-Мансийский автономный округ — Югра", + "Чукотский автономный округ", + "Ямало-Ненецкий автономный округ", +] as const; + +const normalizeForComparison = (value: string): string => value.trim().toLocaleLowerCase("ru-RU"); + +/** Нормализует только безопасные различия регистра и внешних пробелов. */ +export function findCanonicalRussianRegion(value: unknown): string | null { + if (typeof value !== "string") return null; + + const normalized = normalizeForComparison(value); + if (!normalized) return null; + + return russianRegions.find(region => normalizeForComparison(region) === normalized) ?? null; +} + +export function filterRussianRegions(query: string): readonly string[] { + const normalized = normalizeForComparison(query); + if (!normalized) return russianRegions; + + return russianRegions + .filter(region => normalizeForComparison(region).includes(normalized)) + .sort((first, second) => { + const firstNormalized = normalizeForComparison(first); + const secondNormalized = normalizeForComparison(second); + const getRank = (region: string): number => { + if (region === normalized) return 0; + if (region.startsWith(normalized)) return 1; + return 2; + }; + const rankDifference = getRank(firstNormalized) - getRank(secondNormalized); + + if (rankDifference !== 0) return rankDifference; + + const lengthDifference = firstNormalized.length - secondNormalized.length; + if (lengthDifference !== 0) return lengthDifference; + + return first.localeCompare(second, "ru-RU"); + }); +} diff --git a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts index c205cdf1b..8043838b1 100644 --- a/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts +++ b/projects/core/src/lib/interceptors/camelcase.interceptor.spec.ts @@ -1,6 +1,8 @@ /** @format */ import { TestBed } from "@angular/core/testing"; +import { HttpRequest, HttpResponse } from "@angular/common/http"; +import { of } from "rxjs"; import { CamelcaseInterceptor } from "./camelcase.interceptor"; @@ -15,4 +17,268 @@ describe("CamelcaseInterceptor", () => { const interceptor: CamelcaseInterceptor = TestBed.inject(CamelcaseInterceptor); expect(interceptor).toBeTruthy(); }); + + it("converts the program-scoped current application contract to camelCase", () => { + const interceptor = TestBed.inject(CamelcaseInterceptor); + const body = { + current_project_application: { + project_id: 123, + program_link_id: 700, + submitted: false, + }, + }; + + interceptor + .intercept(new HttpRequest("GET", "/programs/12/"), { + handle: () => of(new HttpResponse({ body })), + }) + .subscribe(event => { + if (!(event instanceof HttpResponse)) return; + expect(event.body).toEqual({ + currentProjectApplication: { + projectId: 123, + programLinkId: 700, + submitted: false, + }, + }); + }); + }); + + it("преобразует expert drilldown и SLA с фактическими overdue24H/overdue48H", () => { + const interceptor = TestBed.inject(CamelcaseInterceptor); + const body = { + attention: { + delayed_experts: { + total: 1, + items: [{ expert_id: 4, overdue_24h: 3, overdue_48h: 1, oldest_waiting_seconds: 187200 }], + }, + }, + assignments: [ + { + assignment_id: 17, + criteria_total: 5, + criteria_scored: 2, + waiting_seconds: 108000, + expert: { full_name: "Иван Иванов", user_id: 123 }, + scores: [{ criterion_id: 1, min_value: 0, is_scored: true }], + }, + ], + }; + interceptor + .intercept( + new HttpRequest>("GET", "/programs/12/project-analytics/"), + { handle: () => of(new HttpResponse({ body })) }, + ) + .subscribe(event => { + if (!(event instanceof HttpResponse)) return; + expect(event.body).toEqual({ + attention: { + delayedExperts: { + total: 1, + items: [{ expertId: 4, overdue24H: 3, overdue48H: 1, oldestWaitingSeconds: 187200 }], + }, + }, + assignments: [ + { + assignmentId: 17, + criteriaTotal: 5, + criteriaScored: 2, + waitingSeconds: 108000, + expert: { fullName: "Иван Иванов", userId: 123 }, + scores: [{ criterionId: 1, minValue: 0, isScored: true }], + }, + ], + }); + }); + }); + + it("attention pages: вложенные snake_case поля и null проходят без потерь", () => { + const interceptor = TestBed.inject(CamelcaseInterceptor); + const body = { + count: 1, + next: null, + previous: null, + mode: "open", + results: [ + { + program_project_id: 70, + user_id: 123, + registered_at: null, + submitted_at: null, + full_name: "Анна", + reason_label: "Ожидает первой оценки", + assignments_total: null, + assignments_completed: null, + leader: { user_id: 123, full_name: "Анна", avatar: null }, + }, + ], + }; + interceptor + .intercept( + new HttpRequest("GET", "/programs/12/project-analytics/projects-awaiting-evaluation/"), + { handle: () => of(new HttpResponse({ body })) }, + ) + .subscribe(event => { + if (!(event instanceof HttpResponse)) return; + expect(event.body).toEqual({ + count: 1, + next: null, + previous: null, + mode: "open", + results: [ + { + programProjectId: 70, + userId: 123, + registeredAt: null, + submittedAt: null, + fullName: "Анна", + reasonLabel: "Ожидает первой оценки", + assignmentsTotal: null, + assignmentsCompleted: null, + leader: { userId: 123, fullName: "Анна", avatar: null }, + }, + ], + }); + }); + }); + + it("not-submitted: overview, nullable metadata и вложенные поля преобразуются автоматически", () => { + const interceptor = TestBed.inject(CamelcaseInterceptor); + const body = { + attention: { projects_not_submitted: { applicable: true, total: 1 } }, + count: 1, + next: null, + previous: null, + applicable: true, + submission_deadline: null, + submission_open: true, + results: [ + { + program_project_id: 70, + project: { id: 55, name: "Проект" }, + leader: { user_id: 123, full_name: "Анна", avatar: null }, + linked_at: "2026-09-01T10:00:00Z", + }, + ], + }; + interceptor + .intercept(new HttpRequest("GET", "/programs/12/project-analytics/projects-not-submitted/"), { + handle: () => of(new HttpResponse({ body })), + }) + .subscribe(event => { + if (!(event instanceof HttpResponse)) return; + expect(event.body).toEqual({ + attention: { projectsNotSubmitted: { applicable: true, total: 1 } }, + count: 1, + next: null, + previous: null, + applicable: true, + submissionDeadline: null, + submissionOpen: true, + results: [ + { + programProjectId: 70, + project: { id: 55, name: "Проект" }, + leader: { userId: 123, fullName: "Анна", avatar: null }, + linkedAt: "2026-09-01T10:00:00Z", + }, + ], + }); + }); + }); + + it("преобразует вложенный manager overview из snake_case в domain camelCase", () => { + const interceptor = TestBed.inject(CamelcaseInterceptor); + const request = new HttpRequest("GET", "/programs/12/project-analytics/"); + const response = new HttpResponse({ + body: { + summary: { + participant_regions: { total: 1, items: [{ name: "Набережные Челны", count: 3 }] }, + }, + participant_funnel: { unique_participants: 3 }, + evaluation_status: { + max_evaluations_per_project: 2, + projects: { awaiting_evaluation: 1, partially_evaluated: 1 }, + }, + activity: [{ date: "2026-08-01", submitted_solutions: 1 }], + cases: { + configured: true, + submission_applicable: true, + items: [ + { + name: "Case A", + participants_total: 7, + projects_total: 3, + not_submitted: 1, + submitted: 2, + }, + { + name: "Case B", + participants_total: 4, + projects_total: 1, + not_submitted: 0, + submitted: 1, + }, + { + name: "Case C", + participants_total: 0, + projects_total: 0, + not_submitted: 0, + submitted: 0, + }, + ], + without_case: { + participants_total: 3, + projects_total: 2, + not_submitted: 2, + submitted: 0, + }, + }, + }, + }); + + interceptor.intercept(request, { handle: () => of(response) }).subscribe(event => { + if (!(event instanceof HttpResponse)) return; + + expect(event.body).toEqual({ + summary: { + participantRegions: { total: 1, items: [{ name: "Набережные Челны", count: 3 }] }, + }, + participantFunnel: { uniqueParticipants: 3 }, + evaluationStatus: { + maxEvaluationsPerProject: 2, + projects: { awaitingEvaluation: 1, partiallyEvaluated: 1 }, + }, + activity: [{ date: "2026-08-01", submittedSolutions: 1 }], + cases: { + configured: true, + submissionApplicable: true, + items: [ + { + name: "Case A", + participantsTotal: 7, + projectsTotal: 3, + notSubmitted: 1, + submitted: 2, + }, + { + name: "Case B", + participantsTotal: 4, + projectsTotal: 1, + notSubmitted: 0, + submitted: 1, + }, + { + name: "Case C", + participantsTotal: 0, + projectsTotal: 0, + notSubmitted: 0, + submitted: 0, + }, + ], + withoutCase: { participantsTotal: 3, projectsTotal: 2, notSubmitted: 2, submitted: 0 }, + }, + }); + }); + }); }); diff --git a/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.spec.ts b/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.spec.ts new file mode 100644 index 000000000..44a712422 --- /dev/null +++ b/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.spec.ts @@ -0,0 +1,95 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { mapInviteSendError } from "./map-invite-send-error"; + +describe("mapInviteSendError", () => { + it.each([ + [ + "Пользователь уже является лидером проекта.", + "already_leader", + "Вы уже являетесь руководителем этого проекта.", + ], + [ + "Пользователь уже состоит в проекте.", + "already_member", + "Пользователь уже состоит в команде проекта.", + ], + [ + "У пользователя уже есть активное приглашение в этот проект.", + "already_invited", + "Этому пользователю уже отправлено приглашение.", + ], + [ + "Нельзя пригласить пользователя: проект относится к программе, а пользователь не является её участником.", + "not_program_participant", + "Пригласить можно только участника этой программы.", + ], + [ + 'Invalid pk "999" - object does not exist.', + "user_not_found", + "Пользователь не найден. Проверьте ссылку на профиль.", + ], + [ + 'Недопустимый первичный ключ "999" - объект не существует.', + "user_not_found", + "Пользователь не найден. Проверьте ссылку на профиль.", + ], + ])("maps expected user validation: %s", (message, kind, text) => { + const result = mapInviteSendError( + new HttpErrorResponse({ status: 400, error: { user: [message] } }), + ); + expect(result).toEqual({ kind, message: text }); + expect(result).not.toHaveProperty("cause"); + }); + + it.each([ + [401, "unauthorized", "Сессия истекла. Войдите в аккаунт снова."], + [403, "forbidden", "Только руководитель проекта может приглашать участников."], + [400, "validation", "Не удалось отправить приглашение. Проверьте данные и попробуйте ещё раз."], + [422, "validation", "Не удалось отправить приглашение. Проверьте данные и попробуйте ещё раз."], + [0, "network", "Не удалось отправить приглашение. Проверьте подключение и попробуйте ещё раз."], + [500, "server", "Не удалось отправить приглашение. Попробуйте ещё раз позже."], + [503, "server", "Не удалось отправить приглашение. Попробуйте ещё раз позже."], + [404, "unknown", "Не удалось отправить приглашение. Попробуйте ещё раз позже."], + ])("status %s ignores arbitrary backend body", (status, kind, message) => { + expect(mapInviteSendError(new HttpErrorResponse({ status, error: "error" }))).toEqual({ + kind, + message, + }); + }); + + it.each([ + { role: ["Ensure this field has no more than 100 characters."] }, + { specialization: ["Invalid pk - object does not exist."] }, + { user: ["Unknown English DRF error."] }, + { user: { nested: "Пользователь уже состоит в проекте." } }, + null, + ])("unknown validation stays controlled: %j", error => { + expect(mapInviteSendError(new HttpErrorResponse({ status: 400, error }))).toEqual({ + kind: "validation", + message: "Не удалось отправить приглашение. Проверьте данные и попробуйте ещё раз.", + }); + }); + + it("does not interpret expected validation text in a server error", () => { + expect( + mapInviteSendError( + new HttpErrorResponse({ + status: 500, + error: { user: ["Пользователь уже состоит в проекте."] }, + }), + ).kind, + ).toBe("server"); + }); + + it.each([new Error("raw sensitive error"), undefined, {}, "error"])( + "unknown error is safe", + error => { + expect(mapInviteSendError(error)).toEqual({ + kind: "unknown", + message: "Не удалось отправить приглашение. Попробуйте ещё раз позже.", + }); + }, + ); +}); diff --git a/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.ts b/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.ts new file mode 100644 index 000000000..b9c586476 --- /dev/null +++ b/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { InviteSendError } from "@domain/invite/invite-send-error"; + +const messages: Record = { + user_not_found: "Пользователь не найден. Проверьте ссылку на профиль.", + already_leader: "Вы уже являетесь руководителем этого проекта.", + already_member: "Пользователь уже состоит в команде проекта.", + already_invited: "Этому пользователю уже отправлено приглашение.", + not_program_participant: "Пригласить можно только участника этой программы.", + unauthorized: "Сессия истекла. Войдите в аккаунт снова.", + forbidden: "Только руководитель проекта может приглашать участников.", + validation: "Не удалось отправить приглашение. Проверьте данные и попробуйте ещё раз.", + network: "Не удалось отправить приглашение. Проверьте подключение и попробуйте ещё раз.", + server: "Не удалось отправить приглашение. Попробуйте ещё раз позже.", + unknown: "Не удалось отправить приглашение. Попробуйте ещё раз позже.", +}; + +const userErrors = new Map([ + ["Пользователь уже является лидером проекта.", "already_leader"], + ["Пользователь уже состоит в проекте.", "already_member"], + ["У пользователя уже есть активное приглашение в этот проект.", "already_invited"], + [ + "Нельзя пригласить пользователя: проект относится к программе, а пользователь не является её участником.", + "not_program_participant", + ], +]); + +function mapped(kind: InviteSendError["kind"]): InviteSendError { + return { kind, message: messages[kind] }; +} + +/** Recognizes only expected user validation errors; all other bodies stay private. */ +export function mapInviteSendError(error: unknown): InviteSendError { + if (!(error instanceof HttpErrorResponse)) return mapped("unknown"); + if (error.status === 0) return mapped("network"); + if (error.status === 401) return mapped("unauthorized"); + if (error.status === 403) return mapped("forbidden"); + if (error.status >= 500) return mapped("server"); + if (error.status !== 400 && error.status !== 422) return mapped("unknown"); + + const body: unknown = error.error; + const user = body && typeof body === "object" && "user" in body ? body.user : undefined; + if (Array.isArray(user)) { + for (const message of user) { + if (typeof message !== "string") continue; + const kind = userErrors.get(message); + if (kind) return mapped(kind); + // DRF PrimaryKeyRelatedField, English and Russian translations, user field only. + if ( + /invalid pk|object does not exist|недопустимый первичный ключ|объект.*не существует/i.test( + message, + ) + ) { + return mapped("user_not_found"); + } + } + } + return mapped("validation"); +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.spec.ts b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.spec.ts index 81953ce89..bc206079e 100644 --- a/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.spec.ts +++ b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.spec.ts @@ -5,6 +5,7 @@ import { of, throwError } from "rxjs"; import { SendForUserUseCase } from "./send-for-user.use-case"; import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; import { Invite } from "@domain/invite/invite.model"; +import { HttpErrorResponse } from "@angular/common/http"; describe("SendForUserUseCase", () => { let useCase: SendForUserUseCase; @@ -40,17 +41,22 @@ describe("SendForUserUseCase", () => { }); })); - it("при ошибке возвращает fail { kind: 'invite_error' } с cause", () => + it("преобразует HTTP validation в controlled Result без raw cause", () => new Promise(done => { setup(); - const boom = new Error("boom"); + const boom = new HttpErrorResponse({ + status: 400, + error: { user: ["Пользователь уже состоит в проекте."] }, + }); repo.sendForUser.mockReturnValue(throwError(() => boom)); useCase.execute({ userId: 1, projectId: 2, role: "member" }).subscribe(result => { expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error.kind).toBe("invite_error"); - expect(result.error.cause).toBe(boom); + expect(result.error).toEqual({ + kind: "already_member", + message: "Пользователь уже состоит в команде проекта.", + }); } done(); }); diff --git a/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts index 7a86d79c1..3b4cabf1e 100644 --- a/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts +++ b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts @@ -6,8 +6,10 @@ import { SendForUserCommand } from "@domain/invite/commands/send-for-user.comman import { catchError, map, Observable, of } from "rxjs"; import { fail, ok, Result } from "@domain/shared/result.type"; import { Invite } from "@domain/invite/invite.model"; +import { InviteSendError } from "@domain/invite/invite-send-error"; +import { mapInviteSendError } from "../mappers/map-invite-send-error"; -/** Сценарий: отправить приглашение пользователю в проект; ошибка → `invite_error`. */ +/** Отправляет приглашение, преобразуя HTTP-ошибки в безопасный контракт формы. */ @Injectable({ providedIn: "root" }) export class SendForUserUseCase { private readonly inviteRepositoryPort = inject(InviteRepositoryPort); @@ -17,10 +19,10 @@ export class SendForUserUseCase { projectId, role, specialization, - }: SendForUserCommand): Observable> { + }: SendForUserCommand): Observable> { return this.inviteRepositoryPort.sendForUser(userId, projectId, role, specialization).pipe( map(invite => ok(invite)), - catchError(error => of(fail({ kind: "invite_error" as const, cause: error }))), + catchError(error => of(fail(mapInviteSendError(error)))), ); } } diff --git a/projects/social_platform/src/app/api/paths/app-routes.ts b/projects/social_platform/src/app/api/paths/app-routes.ts index dde53dcb3..d0dbd8d7d 100644 --- a/projects/social_platform/src/app/api/paths/app-routes.ts +++ b/projects/social_platform/src/app/api/paths/app-routes.ts @@ -47,6 +47,7 @@ export const AppRoutes = { detail: (programId: number | string) => `/office/program/${programId}`, projects: (programId: number | string) => `/office/program/${programId}/projects`, projectsRating: (programId: number | string) => `/office/program/${programId}/projects-rating`, + analytics: (programId: number | string) => `/office/program/${programId}/analytics`, members: (programId: number | string) => `/office/program/${programId}/members`, register: (programId: number | string) => `/office/program/${programId}/register`, }, diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts index fb2380419..9b303cd7c 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.spec.ts @@ -82,6 +82,7 @@ describe("ProfileEditInfoService", () => { first_name: "Иван", last_name: "Иванов", birthday: "1990-03-25", + city: "Москва", links: ["https://t.me/procollab"], skillsIds: [1], }), @@ -107,4 +108,44 @@ describe("ProfileEditInfoService", () => { expect(service.profileFormSubmitting$().status).toBe("failure"); expect(service.isModalErrorSkillsChoose()).toBe(true); }); + + it.each(["Республика Татарстан", "Набережные Челны"])( + "saves user region through city without changing %s", + city => { + profileFormService.profileId.set(42); + profileFormService.getForm().patchValue({ + firstName: "Иван", + lastName: "Иванов", + birthday: "25.03.1990", + city, + speciality: "Frontend developer", + skills: [{ id: 1, name: "Angular" }] as Skill[], + }); + + service.saveProfile(); + + expect(saveProfileUseCase.execute).toHaveBeenCalledOnce(); + const payload = saveProfileUseCase.execute.mock.calls[0][1]; + expect(payload.city).toBe(city); + expect(payload).not.toHaveProperty("region"); + }, + ); + + it("blocks saving an uncommitted region search before API and uses the region error label", () => { + profileFormService.profileId.set(42); + profileFormService.getForm().patchValue({ + firstName: "Иван", + lastName: "Иванов", + birthday: "25.03.1990", + city: "", + speciality: "Frontend developer", + skills: [{ id: 1, name: "Angular" }] as Skill[], + }); + + service.saveProfile(); + + expect(saveProfileUseCase.execute).not.toHaveBeenCalled(); + expect(service.isModalErrorSkillChooseText()).toContain("Регион"); + expect(service.isModalErrorSkillChooseText()).not.toContain("Город"); + }); }); diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts index 8b0bff3ff..ad633bf9a 100644 --- a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts @@ -253,7 +253,7 @@ export class ProfileEditInfoService { ); if (specialityError) return specialityError; - const cityError = this.resolveControlErrorText("Город", this.profileForm.get("city")); + const cityError = this.resolveControlErrorText("Регион", this.profileForm.get("city")); if (cityError) return cityError; const phoneError = this.resolveControlErrorText("Телефон", this.profileForm.get("phoneNumber")); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts new file mode 100644 index 000000000..269407ae0 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts @@ -0,0 +1,174 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, Subject } from "rxjs"; +import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; +import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; +import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; +import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; +import { + participantsPage, + projectsPage, + notSubmittedPage, +} from "@domain/program/program-analytics-attention.fixture"; +import { fail, ok } from "@domain/shared/result.type"; +import { + ProgramAnalyticsDrilldownService, + AnalyticsAttentionView, +} from "./program-analytics-drilldown.service"; + +describe.each([ + "participants-without-team", + "projects-awaiting-evaluation", + "projects-not-submitted", +] as AnalyticsAttentionView[])("Attention facade: %s", view => { + const participants = { execute: vi.fn() }; + const projects = { execute: vi.fn() }; + const notSubmitted = { execute: vi.fn() }; + const assignments = { execute: vi.fn().mockReturnValue(of(ok([]))) }; + const response = () => + view === "participants-without-team" + ? participantsPage() + : view === "projects-not-submitted" + ? notSubmittedPage() + : projectsPage(); + const active = () => + view === "participants-without-team" + ? participants + : view === "projects-not-submitted" + ? notSubmitted + : projects; + let service: ProgramAnalyticsDrilldownService; + beforeEach(() => { + participants.execute.mockReset().mockReturnValue(of(ok(participantsPage()))); + projects.execute.mockReset().mockReturnValue(of(ok(projectsPage()))); + notSubmitted.execute.mockReset().mockReturnValue(of(ok(notSubmittedPage()))); + assignments.execute.mockClear(); + TestBed.configureTestingModule({ + providers: [ + ProgramAnalyticsDrilldownService, + { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: notSubmitted }, + { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, + { provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, useValue: projects }, + { provide: GetProgramManagerAssignmentsUseCase, useValue: assignments }, + { provide: GetProgramManagerAssignmentScoresUseCase, useValue: { execute: vi.fn() } }, + ], + }); + service = TestBed.inject(ProgramAnalyticsDrilldownService); + }); + + it("неприменимый или неподтверждённый сценарий не загружается", () => { + service.openAttention(12, "projects-not-submitted", false); + service.openAttention(12, "projects-not-submitted"); + expect(notSubmitted.execute).not.toHaveBeenCalled(); + expect(service.open()).toBe(false); + }); + + it("lazy open, свежий count, нет назначения/автоподгрузки страниц", () => { + expect(active().execute).not.toHaveBeenCalled(); + service.openAttention(12, view, true); + expect(active().execute).toHaveBeenCalledExactlyOnceWith(12, { + search: "", + limit: 25, + offset: 0, + }); + expect(service.attentionCount()).toBe(1); + expect(service.attentionRange()).toBe("1–1 из 1"); + expect(service.attentionPage()).toEqual(response()); + expect(assignments.execute).not.toHaveBeenCalled(); + }); + + it("draft без request, search trim/reset offset, clear, next/previous", () => { + const page = response(); + active().execute.mockReturnValue( + of(ok({ ...page, count: 61, results: Array.from({ length: 25 }, () => page.results[0]) })), + ); + service.openAttention(12, view, true); + service.changeAttentionPage(1); + expect(service.attentionOffset()).toBe(25); + expect(service.attentionRange()).toBe("26–50 из 61"); + service.changeAttentionPage(-1); + expect(service.attentionOffset()).toBe(0); + service.changeAttentionPage(1); + service.searchDraft.set(" Анна "); + expect(active().execute).toHaveBeenCalledTimes(4); + service.applyAttentionSearch(); + expect(active().execute).toHaveBeenLastCalledWith(12, { search: "Анна", limit: 25, offset: 0 }); + service.clearAttentionSearch(); + expect(active().execute).toHaveBeenLastCalledWith(12, { search: "", limit: 25, offset: 0 }); + }); + + it("search empty не error; repeated open очищает draft и старые results", () => { + service.openAttention(12, view, true); + active().execute.mockReturnValueOnce(of(ok({ ...response(), count: 0, results: [] }))); + service.searchDraft.set("несуществующий"); + service.applyAttentionSearch(); + expect(service.attentionCount()).toBe(0); + expect(service.attentionError()).toBeNull(); + expect(service.appliedSearch()).toBe("несуществующий"); + const pending = new Subject(); + active().execute.mockReturnValueOnce(pending); + service.openAttention(12, view, true); + expect(service.searchDraft()).toBe(""); + expect(service.appliedSearch()).toBe(""); + expect(service.attentionPage()).toBeNull(); + expect(service.attentionPending()).toBe(true); + }); + + it.each(["unauthorized", "forbidden", "not_found", "network"] as const)( + "retry %s сохраняет search/offset", + kind => { + active().execute.mockReturnValue(of(ok({ ...response(), count: 61 }))); + service.openAttention(12, view, true); + service.searchDraft.set("Анна"); + service.applyAttentionSearch(); + active().execute.mockReturnValueOnce(of(fail({ kind }))); + service.changeAttentionPage(1); + expect(service.attentionError()).toEqual({ kind }); + expect(service.open()).toBe(true); + expect(service.attentionPage()).toBeNull(); + service.loadAttention(); + expect(active().execute).toHaveBeenLastCalledWith(12, { + search: "Анна", + limit: 25, + offset: 25, + }); + expect(service.attentionError()).toBeNull(); + }, + ); + + it.each(["search", "page", "close", "other-root", "program", "destroy"])( + "%s отменяет Subject и поздний ответ", + action => { + const old = new Subject(); + active().execute.mockReturnValueOnce(old); + service.openAttention(12, view, true); + expect(old.observed).toBe(true); + switch (action) { + case "search": + service.searchDraft.set("Анна"); + service.applyAttentionSearch(); + break; + case "page": + service.attentionOffset.set(25); + service.changeAttentionPage(-1); + break; + case "other-root": + service.openAssignments(12, "all"); + break; + case "program": + service.openAttention(13, view, true); + break; + case "destroy": + TestBed.resetTestingModule(); + break; + default: + service.close(); + } + expect(old.observed).toBe(false); + const current = service.attentionPage(); + old.next(ok({ ...response(), count: 999 })); + expect(service.attentionPage()).toBe(current); + }, + ); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts new file mode 100644 index 000000000..1dc00e783 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts @@ -0,0 +1,146 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; +import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; +import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; +import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; +import { provideRouter } from "@angular/router"; +import { + ProgramAnalyticsAssignment, + ProgramAnalyticsAssignmentScoreDetail, + ProgramAnalyticsError, +} from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { + assignment, + delayedExpert, + scoreDetail, +} from "@domain/program/program-analytics-assignment.fixture"; +import { of, Subject } from "rxjs"; +import { ProgramAnalyticsDrilldownService } from "./program-analytics-drilldown.service"; + +describe("ProgramAnalyticsDrilldownService", () => { + let service: ProgramAnalyticsDrilldownService; + const assignments = { execute: vi.fn() }; + const scores = { execute: vi.fn() }; + beforeEach(() => { + assignments.execute.mockReset().mockReturnValue(of(ok([assignment()]))); + scores.execute.mockReset().mockReturnValue(of(ok(scoreDetail()))); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, + { + provide: GetProgramManagerParticipantsWithoutTeamUseCase, + useValue: { execute: vi.fn() }, + }, + { + provide: GetProgramManagerProjectsAwaitingEvaluationUseCase, + useValue: { execute: vi.fn() }, + }, + ProgramAnalyticsDrilldownService, + { provide: GetProgramManagerAssignmentsUseCase, useValue: assignments }, + { provide: GetProgramManagerAssignmentScoresUseCase, useValue: scores }, + ], + }); + service = TestBed.inject(ProgramAnalyticsDrilldownService); + }); + + it("не загружает до открытия и обновляет список при следующем открытии", () => { + expect(assignments.execute).not.toHaveBeenCalled(); + service.openAssignments(12, "completed"); + expect(assignments.execute).toHaveBeenCalledWith(12, "completed"); + service.loadAssignmentScores(17); + expect(service.view()).toBe("scores"); + service.back(); + expect(service.view()).toBe("assignments"); + expect(service.scoreDetail()).toBeNull(); + expect(assignments.execute).toHaveBeenCalledTimes(1); + service.close(); + service.openAssignments(12, "completed"); + expect(assignments.execute).toHaveBeenCalledTimes(2); + }); + + it.each([0, -1, NaN, 1.5])("не делает запрос с некорректной программой %s", id => { + service.openAssignments(id, "all"); + expect(service.open()).toBe(false); + expect(assignments.execute).not.toHaveBeenCalled(); + }); + + it("отменяет старые ответы при смене программы и закрытии", () => { + const old = new Subject>(); + assignments.execute.mockReturnValueOnce(old); + service.openAssignments(12, "all"); + expect(service.assignmentsPending()).toBe(true); + service.openAssignments(13, "pending"); + expect(old.observed).toBe(false); + old.next(ok([assignment({ assignmentId: 999 })])); + expect(service.assignments()[0].assignmentId).toBe(17); + service.close(); + expect(service.assignments()).toEqual([]); + expect(service.selectedExpert()).toBeNull(); + }); + + it("отменяет score request при Back и destroy", () => { + const response = new Subject< + Result + >(); + scores.execute.mockReturnValue(response); + service.openAssignments(12, "completed"); + service.loadAssignmentScores(17); + expect(service.scoreDetailPending()).toBe(true); + service.back(); + expect(response.observed).toBe(false); + response.next(ok(scoreDetail())); + expect(service.scoreDetail()).toBeNull(); + service.loadAssignmentScores(17); + TestBed.resetTestingModule(); + expect(response.observed).toBe(false); + }); + + it("не позволяет запросить detail произвольного/несданного назначения", () => { + assignments.execute.mockReturnValue(of(ok([assignment({ status: "not_ready" })]))); + service.openAssignments(12, "all"); + service.loadAssignmentScores(99); + service.loadAssignmentScores(17); + expect(scores.execute).not.toHaveBeenCalled(); + }); + + it("сохраняет backend order/severity; backlog строго expertId, not_ready отдельно", () => { + const expert = delayedExpert(); + assignments.execute.mockReturnValue( + of( + ok([ + assignment({ assignmentId: 1, status: "pending" }), + assignment({ assignmentId: 2, status: "not_ready" }), + assignment({ assignmentId: 3, status: "pending", expert: { ...expert, expertId: 999 } }), + ]), + ), + ); + service.openDelayed(12, { total: 1, items: [expert] }); + expect(service.delayedExperts()).toEqual([expert]); + expect(assignments.execute).toHaveBeenCalledWith(12, "pending"); + service.showBacklog(expert); + expect(service.waitingBacklog().map(item => item.assignmentId)).toEqual([1]); + expect(service.notReadyBacklog().map(item => item.assignmentId)).toEqual([2]); + service.back(); + expect(service.view()).toBe("delayed"); + expect(assignments.execute).toHaveBeenCalledTimes(1); + }); + + it("ошибки retry не закрывают delayed list/detail", () => { + assignments.execute.mockReturnValueOnce(of(fail({ kind: "network" }))); + service.openDelayed(12, { total: 1, items: [delayedExpert()] }); + expect(service.delayedExperts()).toHaveLength(1); + expect(service.assignmentsError()?.kind).toBe("network"); + expect(service.open()).toBe(true); + service.loadAssignments(); + expect(service.assignmentsError()).toBeNull(); + scores.execute.mockReturnValueOnce(of(fail({ kind: "forbidden" }))); + service.loadAssignmentScores(17); + expect(service.scoreDetailError()?.kind).toBe("forbidden"); + service.retryScores(); + expect(service.scoreDetail()).toEqual(scoreDetail()); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts new file mode 100644 index 000000000..6aa602192 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts @@ -0,0 +1,308 @@ +/** @format */ +import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Subject, takeUntil } from "rxjs"; +import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; +import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; +import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "@api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case"; +import { GetProgramManagerProjectsNotSubmittedUseCase } from "@api/program/use-cases/get-program-manager-projects-not-submitted.use-case"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, +} from "@domain/program/program-analytics-attention.model"; +import { + ProgramAnalyticsAssignment, + ProgramAnalyticsAssignmentScope, + ProgramAnalyticsAssignmentScoreDetail, + ProgramAnalyticsDelayedExpert, + ProgramAnalyticsDelayedExperts, + ProgramAnalyticsError, +} from "@domain/program/program-analytics.model"; + +/** Root-списки внимания используют тот же modal context, не вложенную модалку. */ +export type AnalyticsAttentionView = + | "participants-without-team" + | "projects-awaiting-evaluation" + | "projects-not-submitted"; +export type AnalyticsDrilldownView = + | "assignments" + | "scores" + | "delayed" + | "backlog" + | AnalyticsAttentionView; + +/** Живёт вместе с одной analytics-модалкой, не сохраняет manager-данные между открытиями. */ +@Injectable() +export class ProgramAnalyticsDrilldownService { + private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase); + private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase); + private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase); + private readonly getProjects = inject(GetProgramManagerProjectsAwaitingEvaluationUseCase); + private readonly getNotSubmitted = inject(GetProgramManagerProjectsNotSubmittedUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly cancelAssignments = new Subject(); + private readonly cancelScores = new Subject(); + private readonly cancelAttention = new Subject(); + private programId: number | null = null; + private selectedAssignmentId: number | null = null; + + readonly open = signal(false); + readonly view = signal("assignments"); + readonly scope = signal("all"); + readonly assignments = signal([]); + readonly assignmentsPending = signal(false); + readonly assignmentsError = signal(null); + readonly scoreDetail = signal(null); + readonly scoreDetailPending = signal(false); + readonly scoreDetailError = signal(null); + readonly delayedExperts = signal([]); + readonly selectedExpert = signal(null); + + readonly searchDraft = signal(""); + readonly appliedSearch = signal(""); + readonly attentionLimit = 25; + readonly attentionOffset = signal(0); + readonly participantsPage = + signal | null>(null); + readonly projectsPage = signal(null); + readonly notSubmittedPage = signal(null); + readonly attentionPending = signal(false); + readonly attentionError = signal(null); + readonly isAttentionView = computed( + () => + this.view() === "participants-without-team" || + this.view() === "projects-awaiting-evaluation" || + this.view() === "projects-not-submitted", + ); + readonly attentionPage = computed(() => + this.view() === "participants-without-team" + ? this.participantsPage() + : this.view() === "projects-not-submitted" + ? this.notSubmittedPage() + : this.projectsPage(), + ); + readonly attentionCount = computed(() => this.attentionPage()?.count ?? 0); + readonly attentionHasNext = computed(() => { + const page = this.attentionPage(); + return !!page && this.attentionOffset() + page.results.length < page.count; + }); + readonly attentionRange = computed(() => { + const page = this.attentionPage(); + if (!page?.results.length) return `0 из ${page?.count ?? 0}`; + return `${this.attentionOffset() + 1}–${this.attentionOffset() + page.results.length} из ${page.count}`; + }); + + constructor() { + this.destroyRef.onDestroy(() => this.close()); + } + + /** Каждое root-open очищает поиск/страницу/старые данные и делает ровно один запрос. */ + openAttention( + programId: number, + view: AnalyticsAttentionView, + notSubmittedApplicable = false, + ): void { + // Только явно подтверждённая overview применимость разрешает новый сценарий. + if (view === "projects-not-submitted" && !notSubmittedApplicable) return; + if (!this.start(programId)) return; + this.view.set(view); + this.loadAttention(); + } + + /** Query остаётся черновиком до Enter; сервер получает trimmed search с первой страницы. */ + applyAttentionSearch(): void { + if (!this.open() || !this.isAttentionView()) return; + this.appliedSearch.set(this.searchDraft().trim()); + this.attentionOffset.set(0); + this.loadAttention(); + } + + /** Явная очистка возвращает первую страницу полного списка. */ + clearAttentionSearch(): void { + this.searchDraft.set(""); + this.applyAttentionSearch(); + } + + /** Переход отменяет запрос; ссылка next от backend не используется как произвольный URL. */ + changeAttentionPage(direction: -1 | 1): void { + if (!this.open() || !this.isAttentionView()) return; + if (direction === 1 && (this.attentionPending() || !this.attentionHasNext())) return; + if (direction === -1 && this.attentionOffset() === 0) return; + this.attentionOffset.update(offset => Math.max(0, offset + direction * this.attentionLimit)); + this.loadAttention(); + } + + /** Retry сохраняет search/offset; takeUntil не допускает позднего ответа прошлого контекста. */ + loadAttention(): void { + if (!this.open() || this.programId === null || !this.isAttentionView()) return; + this.cancelAttention.next(); + this.participantsPage.set(null); + this.projectsPage.set(null); + this.notSubmittedPage.set(null); + this.attentionError.set(null); + this.attentionPending.set(true); + const query = { + search: this.appliedSearch(), + limit: this.attentionLimit, + offset: this.attentionOffset(), + }; + if (this.view() === "participants-without-team") { + this.getParticipants + .execute(this.programId, query) + .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.attentionPending.set(false); + if (result.ok) this.participantsPage.set(result.value); + else this.attentionError.set(result.error); + }); + } else if (this.view() === "projects-not-submitted") { + this.getNotSubmitted + .execute(this.programId, query) + .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.attentionPending.set(false); + if (result.ok) this.notSubmittedPage.set(result.value); + else this.attentionError.set(result.error); + }); + } else { + this.getProjects + .execute(this.programId, query) + .pipe(takeUntil(this.cancelAttention), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.attentionPending.set(false); + if (result.ok) this.projectsPage.set(result.value); + else this.attentionError.set(result.error); + }); + } + } + + /** Фильтруем по expertId, не userId/имени; порядок backend внутри группы сохраняется. */ + readonly backlog = computed(() => + this.assignments().filter(item => item.expert.expertId === this.selectedExpert()?.expertId), + ); + readonly waitingBacklog = computed(() => + this.backlog().filter(item => item.status !== "not_ready"), + ); + readonly notReadyBacklog = computed(() => + this.backlog().filter(item => item.status === "not_ready"), + ); + + /** Единственная точка открытия: вызывается после проверки контекста программы UI. */ + openAssignments(programId: number, scope: ProgramAnalyticsAssignmentScope): void { + if (!this.start(programId)) return; + this.scope.set(scope); + this.loadAssignments(); + } + + /** Список задержек уже есть в overview; pending запрашиваем только для backlog. */ + openDelayed(programId: number, delayed: ProgramAnalyticsDelayedExperts): void { + if (!this.start(programId)) return; + this.view.set("delayed"); + this.scope.set("pending"); + this.delayedExperts.set(delayed.items); + this.loadAssignments(); + } + + /** Повторная загрузка отменяет предыдущую и не скрывает delayed experts. */ + loadAssignments(): void { + if (!this.open() || this.programId === null) return; + this.cancelAssignments.next(); + this.assignments.set([]); + this.assignmentsError.set(null); + this.assignmentsPending.set(true); + this.getAssignments + .execute(this.programId, this.scope()) + .pipe(takeUntil(this.cancelAssignments), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.assignmentsPending.set(false); + if (result.ok) this.assignments.set(result.value); + else this.assignmentsError.set(result.error); + }); + } + + /** Детализация только назначения из загруженного списка текущей программы. */ + loadAssignmentScores(assignmentId: number): void { + if ( + !this.open() || + this.programId === null || + !this.assignments().some( + item => item.assignmentId === assignmentId && item.status === "completed", + ) + ) + return; + this.cancelScores.next(); + this.selectedAssignmentId = assignmentId; + this.view.set("scores"); + this.scoreDetail.set(null); + this.scoreDetailError.set(null); + this.scoreDetailPending.set(true); + this.getScores + .execute(this.programId, assignmentId) + .pipe(takeUntil(this.cancelScores), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.scoreDetailPending.set(false); + if (result.ok) this.scoreDetail.set(result.value); + else this.scoreDetailError.set(result.error); + }); + } + + retryScores(): void { + if (this.selectedAssignmentId !== null) this.loadAssignmentScores(this.selectedAssignmentId); + } + + showBacklog(expert: ProgramAnalyticsDelayedExpert): void { + if (!this.open() || !this.delayedExperts().includes(expert)) return; + this.selectedExpert.set(expert); + this.view.set("backlog"); + } + + /** Back не перезапрашивает список и отменяет незавершённую загрузку detail. */ + back(): void { + this.cancelScores.next(); + this.scoreDetail.set(null); + this.scoreDetailError.set(null); + this.scoreDetailPending.set(false); + this.selectedAssignmentId = null; + this.selectedExpert.set(null); + this.view.set(this.view() === "backlog" ? "delayed" : "assignments"); + } + + /** Закрытие и смена программы уничтожают весь локальный контекст и активные запросы. */ + close(): void { + this.cancelAssignments.next(); + this.cancelScores.next(); + this.cancelAttention.next(); + this.searchDraft.set(""); + this.appliedSearch.set(""); + this.attentionOffset.set(0); + this.participantsPage.set(null); + this.projectsPage.set(null); + this.notSubmittedPage.set(null); + this.attentionPending.set(false); + this.attentionError.set(null); + this.open.set(false); + this.programId = null; + this.selectedAssignmentId = null; + this.view.set("assignments"); + this.scope.set("all"); + this.assignments.set([]); + this.assignmentsPending.set(false); + this.assignmentsError.set(null); + this.scoreDetail.set(null); + this.scoreDetailPending.set(false); + this.scoreDetailError.set(null); + this.delayedExperts.set([]); + this.selectedExpert.set(null); + } + + private start(programId: number): boolean { + this.close(); + if (!Number.isInteger(programId) || programId <= 0) return false; + this.programId = programId; + this.open.set(true); + return true; + } +} diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts new file mode 100644 index 000000000..9d8d2b11b --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.spec.ts @@ -0,0 +1,247 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, convertToParamMap, ParamMap } from "@angular/router"; +import { GetProgramManagerOverviewUseCase } from "@api/program/use-cases/get-program-manager-overview.use-case"; +import { ProgramAnalyticsOverview } from "@domain/program/program-analytics.model"; +import { Program } from "@domain/program/program.model"; +import { fail, ok } from "@domain/shared/result.type"; +import { BehaviorSubject, of, Subject } from "rxjs"; +import { ProgramAnalyticsInfoService } from "./program-analytics-info.service"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; + +const overview: ProgramAnalyticsOverview = { + summary: { + participants: { total: 2 }, + projects: { total: 7 }, + experts: { total: 1 }, + regions: { total: 1, items: [{ name: "Москва", count: 7 }] }, + participantRegions: { total: 1, items: [{ name: "Набережные Челны", count: 2 }] }, + }, + participantFunnel: { + registrations: 3, + uniqueParticipants: 2, + withTeam: 1, + projectCreators: 1, + submittedProjectCreators: 1, + }, + solutionFunnel: { created: 7, notSubmitted: 6, submitted: 1, evaluated: 0 }, + evaluationStatus: { + mode: "open", + maxEvaluationsPerProject: null, + assignments: { total: 0, pending: 0, evaluated: 0 }, + projects: { submitted: 1, awaitingEvaluation: 1, partiallyEvaluated: 0, evaluated: 0 }, + }, + attention: { + participantsWithoutTeam: 1, + projectsAwaitingEvaluation: 1, + projectsNotSubmitted: { applicable: false, total: 0 }, + delayedExperts: { total: 0, items: [] }, + }, + activity: [], + cases: { + configured: true, + submissionApplicable: true, + items: [ + { name: "Case A", participantsTotal: 2, projectsTotal: 3, notSubmitted: 2, submitted: 1 }, + ], + withoutCase: { participantsTotal: 1, projectsTotal: 4, notSubmitted: 4, submitted: 0 }, + }, +}; + +describe("ProgramAnalyticsInfoService", () => { + let service: ProgramAnalyticsInfoService; + let getOverview: { execute: ReturnType }; + let programUI: ProgramDetailMainUIInfoService; + let paramMap: BehaviorSubject; + + beforeEach(() => { + getOverview = { execute: vi.fn().mockReturnValue(of(ok(overview))) }; + paramMap = new BehaviorSubject(convertToParamMap({ programId: "12" })); + + TestBed.configureTestingModule({ + providers: [ + ProgramAnalyticsInfoService, + ProgramDetailMainUIInfoService, + { provide: GetProgramManagerOverviewUseCase, useValue: getOverview }, + { + provide: ActivatedRoute, + useValue: { parent: { paramMap: paramMap.asObservable() } }, + }, + ], + }); + + service = TestBed.inject(ProgramAnalyticsInfoService); + programUI = TestBed.inject(ProgramDetailMainUIInfoService); + }); + + it("загружает только authoritative manager overview", () => { + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + + service.initialize(); + + expect(service.data()).toBe(overview); + expect(service.data()?.cases).toBe(overview.cases); + expect(service.data()?.summary.projects.total).toBe(7); + expect(service.data()?.summary.participantRegions.items).toEqual([ + { name: "Набережные Челны", count: 2 }, + ]); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + }); + + it("не запрашивает аналитику для пользователя без manager-признака", () => { + programUI.program.set({ ...Program.default(), id: 12, isUserManager: false }); + + service.initialize(); + + expect(service.error()?.kind).toBe("forbidden"); + expect(getOverview.execute).not.toHaveBeenCalled(); + }); + + it("сохраняет loading до завершения manager overview", () => { + const response$ = new Subject>>(); + getOverview.execute.mockReturnValue(response$); + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + + service.initialize(); + + expect(service.pending()).toBe(true); + response$.next(ok(overview)); + response$.complete(); + expect(service.pending()).toBe(false); + }); + + it("немедленно отменяет старую сводку при route change и ждёт соответствующий resolver", () => { + const old = new Subject>>(); + getOverview.execute.mockReturnValueOnce(old); + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + service.initialize(); + TestBed.tick(); + expect(old.observed).toBe(true); + expect(service.programId()).toBe(12); + old.next(ok(overview)); + expect(service.data()).toBe(overview); + + // Like Angular Router, emit params before installing the newly resolved Program. + paramMap.next(convertToParamMap({ programId: "13" })); + + // No effect flush or resolver change is needed to cancel the old context. + expect(old.observed).toBe(false); + expect(service.programId()).toBeNull(); + expect(service.data()).toBeNull(); + expect(service.pending()).toBe(true); + expect(programUI.program()?.id).toBe(12); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + TestBed.tick(); + expect(getOverview.execute).not.toHaveBeenCalledWith(13); + + programUI.program.set({ ...Program.default(), id: 13, isUserManager: true }); + TestBed.tick(); + expect(service.programId()).toBe(13); + expect(getOverview.execute).toHaveBeenCalledTimes(2); + expect(getOverview.execute).toHaveBeenLastCalledWith(13); + old.next( + ok({ ...overview, activity: [{ date: "old", registrations: 999, submittedSolutions: 999 }] }), + ); + expect(service.data()?.activity).toEqual([]); + }); + + it.each(["invalid", "0", "-1", "12.5", "", undefined])( + "при некорректном route ID %s сразу отменяет запрос и показывает not_found", + programId => { + const old = new Subject>>(); + getOverview.execute.mockReturnValue(old); + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + service.initialize(); + TestBed.tick(); + + paramMap.next(convertToParamMap(programId === undefined ? {} : { programId })); + + expect(old.observed).toBe(false); + expect(service.programId()).toBeNull(); + expect(service.data()).toBeNull(); + expect(service.error()?.kind).toBe("not_found"); + service.retry(); + TestBed.tick(); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + }, + ); + + it("не использует старые manager-права для нового route и запрещает non-manager resolver", () => { + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + service.initialize(); + TestBed.tick(); + + paramMap.next(convertToParamMap({ programId: "13" })); + service.retry(); + expect(service.programId()).toBeNull(); + expect(service.pending()).toBe(true); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + + programUI.program.set({ ...Program.default(), id: 13, isUserManager: false }); + TestBed.tick(); + + expect(service.programId()).toBeNull(); + expect(service.error()?.kind).toBe("forbidden"); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + }); + + it("ждёт исходный resolver и загружает совпавшую программу один раз", () => { + service.initialize(); + TestBed.tick(); + expect(service.pending()).toBe(true); + expect(service.programId()).toBeNull(); + expect(getOverview.execute).not.toHaveBeenCalled(); + + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + TestBed.tick(); + + expect(service.programId()).toBe(12); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + }); + + it("повторная эмиссия того же programId не отменяет и не дублирует запрос", () => { + const response = new Subject>>(); + getOverview.execute.mockReturnValue(response); + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + service.initialize(); + TestBed.tick(); + + paramMap.next(convertToParamMap({ programId: "12", unrelated: "changed" })); + TestBed.tick(); + + expect(response.observed).toBe(true); + expect(service.programId()).toBe(12); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + }); + + it("отписывается от route params и незавершённой сводки при destroy", () => { + const response = new Subject>>(); + getOverview.execute.mockReturnValue(response); + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + service.initialize(); + expect(paramMap.observed).toBe(true); + expect(response.observed).toBe(true); + + TestBed.resetTestingModule(); + + expect(paramMap.observed).toBe(false); + expect(response.observed).toBe(false); + paramMap.next(convertToParamMap({ programId: "13" })); + expect(getOverview.execute).toHaveBeenCalledExactlyOnceWith(12); + }); + + it("отображает recoverable error и повторяет запрос", () => { + getOverview.execute + .mockReturnValueOnce(of(fail({ kind: "manager_overview_error", status: 500 }))) + .mockReturnValueOnce(of(ok(overview))); + programUI.program.set({ ...Program.default(), id: 12, isUserManager: true }); + + service.initialize(); + expect(service.error()?.kind).toBe("network"); + + service.retry(); + expect(service.data()).toBe(overview); + expect(getOverview.execute).toHaveBeenCalledTimes(2); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts new file mode 100644 index 000000000..7c0c04d13 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts @@ -0,0 +1,127 @@ +/** @format */ + +import { computed, DestroyRef, effect, inject, Injectable, signal, untracked } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { GetProgramManagerOverviewUseCase } from "@api/program/use-cases/get-program-manager-overview.use-case"; +import { + ProgramAnalyticsError, + ProgramAnalyticsOverview, +} from "@domain/program/program-analytics.model"; +import { + AsyncState, + failure, + initial, + isFailure, + isLoading, + isSuccess, + loading, + success, +} from "@domain/shared/async-state"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; +import { distinctUntilChanged, map, Subject, takeUntil } from "rxjs"; +import { Program } from "@domain/program/program.model"; + +/** Состояние и загрузка внутренней вкладки аналитики программы. */ +@Injectable() +export class ProgramAnalyticsInfoService { + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly getOverview = inject(GetProgramManagerOverviewUseCase); + private readonly programUI = inject(ProgramDetailMainUIInfoService); + private readonly cancel = new Subject(); + private initialized = false; + private routeProgramId: number | null = null; + private loadedProgram: Program | undefined; + readonly programId = signal(null); + + constructor() { + this.route.parent?.paramMap + .pipe( + map(params => { + const id = Number(params.get("programId")); + return Number.isInteger(id) && id > 0 ? id : null; + }), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(programId => { + this.routeProgramId = programId; + // Параметры приходят раньше resolver: сразу сбрасываем предыдущий контекст. + if (this.initialized) this.refresh(); + }); + + effect(() => { + const program = this.programUI.program(); + if (this.initialized && program !== this.loadedProgram) { + untracked(() => this.refresh()); + } + }); + } + + readonly state = signal>(initial()); + readonly data = computed(() => { + const state = this.state(); + return isSuccess(state) ? state.data : null; + }); + readonly pending = computed(() => isLoading(this.state())); + readonly failed = computed(() => isFailure(this.state())); + readonly error = computed(() => { + const state = this.state(); + return isFailure(state) ? state.error : null; + }); + + initialize(): void { + this.initialized = true; + this.refresh(); + } + + /** Запрос разрешён только при совпадении resolved программы с текущим route. */ + private refresh(): void { + const program = this.programUI.program(); + const programId = this.routeProgramId; + this.loadedProgram = program; + this.cancel.next(); + this.programId.set(null); + + if (programId === null) { + this.state.set(failure({ kind: "not_found" })); + return; + } + + if (program?.id !== programId) { + this.state.set(loading()); + return; + } + + if (!program.isUserManager) { + this.state.set(failure({ kind: "forbidden" })); + return; + } + + this.state.set(loading()); + this.programId.set(programId); + this.getOverview + .execute(programId) + .pipe(takeUntil(this.cancel), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + if (!result.ok) { + this.state.set(failure(this.mapError(result.error.status))); + return; + } + + this.state.set(success(result.value)); + }); + } + + retry(): void { + this.initialize(); + } + + private mapError(status: number): ProgramAnalyticsError { + if (status === 401) return { kind: "unauthorized" }; + if (status === 403) return { kind: "forbidden" }; + if (status === 404) return { kind: "not_found" }; + return { kind: "network" }; + } +} diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.spec.ts new file mode 100644 index 000000000..9269f1f81 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.spec.ts @@ -0,0 +1,141 @@ +/** @format */ + +import { signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { firstValueFrom, of, Subject } from "rxjs"; +import { ExpandService } from "@api/expand/expand.service"; +import { NewsInfoService } from "@api/news/news-info.service"; +import { LoadingService } from "@api/shared/loading.service"; +import { AcknowledgeProgramWelcomeUseCase } from "@api/program/use-cases/acknowledge-program-welcome.use-case"; +import { GetProgramUseCase } from "@api/program/use-cases/get-program.use-case"; +import { FetchNewsUseCase } from "@api/program/use-cases/fetch-news.use-case"; +import { ReadNewsUseCase } from "@api/program/use-cases/read-news.use-case"; +import { AddNewsUseCase } from "@api/program/use-cases/add-news.use-case"; +import { DeleteNewsUseCase } from "@api/program/use-cases/delete-news.use-case"; +import { ToggleLikeUseCase } from "@api/program/use-cases/toggle-like.use-case"; +import { EditNewsUseCase } from "@api/program/use-cases/edit-news.use-case"; +import { Program } from "@domain/program/program.model"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramRepository } from "@infrastructure/repository/program/program.repository"; +import { ProgramHttpAdapter } from "@infrastructure/adapters/program/program-http.adapter"; +import { ProgramDetailMainService } from "./program-detail-main-info.service"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; + +describe("ProgramDetailMainService welcome acknowledgement with real repository/cache", () => { + const timestamp = "2026-09-07T10:00:00Z"; + const stale: Program = { + ...Program.default(), + id: 7, + isUserMember: true, + welcomeAcknowledgedAt: null, + }; + const fresh: Program = { ...stale, welcomeAcknowledgedAt: timestamp }; + let adapter: { getOne: ReturnType; acknowledgeWelcome: ReturnType }; + let service: ProgramDetailMainService; + let ui: ProgramDetailMainUIInfoService; + let post: Subject<{ welcomeAcknowledgedAt: string }>; + + beforeEach(() => { + post = new Subject(); + adapter = { + getOne: vi.fn().mockReturnValueOnce(of(stale)).mockReturnValue(of(fresh)), + acknowledgeWelcome: vi + .fn() + .mockReturnValueOnce(post) + .mockReturnValue(of({ welcomeAcknowledgedAt: timestamp })), + }; + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + ProgramDetailMainService, + ProgramDetailMainUIInfoService, + ProgramRepository, + { provide: ProgramRepositoryPort, useExisting: ProgramRepository }, + { provide: ProgramHttpAdapter, useValue: adapter }, + { provide: LoadingService, useValue: {} }, + { provide: ExpandService, useValue: {} }, + { provide: NewsInfoService, useValue: { news: signal([]) } }, + ...[ + FetchNewsUseCase, + ReadNewsUseCase, + AddNewsUseCase, + DeleteNewsUseCase, + ToggleLikeUseCase, + EditNewsUseCase, + ].map(provide => ({ provide, useValue: {} })), + ], + }); + service = TestBed.inject(ProgramDetailMainService); + ui = TestBed.inject(ProgramDetailMainUIInfoService); + }); + + async function loadProgram() { + // Exercise legacy runtime string callers, not just already-normalized route data. + const result = await firstValueFrom( + TestBed.inject(GetProgramUseCase).execute("7" as unknown as number), + ); + expect(result.ok).toBe(true); + if (result.ok) ui.applyFormatingProgramData(result.value); + ui.programId.set("7" as unknown as number); + } + + it("closes only on success, blocks duplicate clicks and does not reopen on a fresh revisit", async () => { + await loadProgram(); + expect(ui.registeredProgramModal()).toBe(true); + expect(ui.program()?.welcomeAcknowledgedAt).toBeNull(); + + service.acknowledgeProgramWelcome().subscribe(); + service.acknowledgeProgramWelcome().subscribe(); + expect(adapter.acknowledgeWelcome).toHaveBeenCalledExactlyOnceWith(7); + expect(ui.welcomeAcknowledgementPending()).toBe(true); + expect(ui.registeredProgramModal()).toBe(true); + expect(ui.program()?.welcomeAcknowledgedAt).toBeNull(); + + post.next({ welcomeAcknowledgedAt: timestamp }); + post.complete(); + expect(ui.welcomeAcknowledgementPending()).toBe(false); + expect(ui.registeredProgramModal()).toBe(false); + expect(ui.program()?.welcomeAcknowledgedAt).toBe(timestamp); + + ui.applyFormatingProgramData({ ...Program.default(), id: 8 }); + await loadProgram(); + expect(adapter.getOne.mock.calls).toEqual([[7], [7]]); + expect(ui.program()).toBe(fresh); + expect(ui.registeredProgramModal()).toBe(false); + expect(adapter.acknowledgeWelcome).toHaveBeenCalledTimes(1); + }); + + it("failed POST retains the unacknowledged program/cache and allows a successful retry", async () => { + await loadProgram(); + service.acknowledgeProgramWelcome().subscribe(); + post.error(new Error("POST failed")); + expect(ui.welcomeAcknowledgementPending()).toBe(false); + expect(ui.registeredProgramModal()).toBe(true); + expect(ui.program()?.welcomeAcknowledgedAt).toBeNull(); + await loadProgram(); + expect(adapter.getOne).toHaveBeenCalledTimes(1); + expect(ui.program()).toBe(stale); + + await firstValueFrom(service.acknowledgeProgramWelcome()); + expect(adapter.acknowledgeWelcome).toHaveBeenCalledTimes(2); + expect(ui.registeredProgramModal()).toBe(false); + expect(ui.program()?.welcomeAcknowledgedAt).toBe(timestamp); + expect(ui.welcomeAcknowledgementPending()).toBe(false); + }); + + it("invalid repository IDs are handled by existing Result errors, without HTTP", async () => { + const get = await firstValueFrom(TestBed.inject(GetProgramUseCase).execute(NaN)); + const acknowledge = await firstValueFrom( + TestBed.inject(AcknowledgeProgramWelcomeUseCase).execute(0), + ); + expect(get.ok).toBe(false); + if (!get.ok) expect(get.error.kind).toBe("get_program_error"); + expect(acknowledge).toEqual({ + ok: false, + error: { kind: "acknowledge_program_welcome_error" }, + }); + expect(adapter.getOne).not.toHaveBeenCalled(); + expect(adapter.acknowledgeWelcome).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts index 63d292f9e..276fb245f 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts @@ -14,6 +14,67 @@ function createProgram(id: number, acknowledgedAt: string | null = null): Progra } describe("ProgramDetailMainUIInfoService", () => { + describe("independent deadlines", () => { + const now = "2026-09-09T12:00:00Z"; + const past = "2026-09-01T00:00:00Z"; + const future = "2026-09-30T00:00:00Z"; + + beforeEach(() => vi.spyOn(Date, "now").mockReturnValue(Date.parse(now))); + afterEach(() => vi.restoreAllMocks()); + + it.each([ + [past, future, true, false], + [future, past, false, true], + [past, now, true, false], + ] as const)( + "registration=%s, evaluation=%s are independent", + (registration, evaluation, registrationExpired, evaluationExpired) => { + const service = new ProgramDetailMainUIInfoService(); + service.applyFormatingProgramData({ + ...createProgram(1), + datetimeRegistrationEnds: registration, + datetimeEvaluationEnds: evaluation, + }); + expect(service.registerDateExpired()).toBe(registrationExpired); + expect(service.evaluationDateExpired()).toBe(evaluationExpired); + }, + ); + + it.each(["", undefined, "not-a-date"])( + "does not close evaluation for legacy/invalid value %s", + evaluation => { + const service = new ProgramDetailMainUIInfoService(); + service.applyFormatingProgramData( + Object.assign(createProgram(1), { + datetimeRegistrationEnds: past, + datetimeEvaluationEnds: evaluation, + }), + ); + expect(service.registerDateExpired()).toBe(true); + expect(service.evaluationDateExpired()).toBe(false); + }, + ); + + it("recalculates closing and reopening from each fresh program response", () => { + const service = new ProgramDetailMainUIInfoService(); + for (const [evaluation, expired] of [ + [past, true], + [future, false], + ["", false], + ] as const) { + service.applyFormatingProgramData({ + ...createProgram(1), + datetimeEvaluationEnds: evaluation, + }); + expect(service.evaluationDateExpired()).toBe(expired); + } + }); + }); + + it("uses null as the required default current application", () => { + expect(Program.default().currentProjectApplication).toBeNull(); + }); + it("показывает приветствие участнику до явного подтверждения", () => { const service = new ProgramDetailMainUIInfoService(); @@ -48,4 +109,18 @@ describe("ProgramDetailMainUIInfoService", () => { expect(service.registeredProgramModal()).toBe(false); expect(service.program()?.welcomeAcknowledgedAt).toBe("2026-08-24T10:00:00Z"); }); + + it("не открывает приветствие при повторном входе со свежим серверным timestamp", () => { + const service = new ProgramDetailMainUIInfoService(); + const timestamp = "2026-09-07T10:00:00Z"; + service.applyFormatingProgramData(createProgram(7)); + expect(service.registeredProgramModal()).toBe(true); + service.applyProgramWelcomeAcknowledged(timestamp); + expect(service.registeredProgramModal()).toBe(false); + + service.applyFormatingProgramData({ ...Program.default(), id: 8 }); + service.applyFormatingProgramData(createProgram(7, timestamp)); + expect(service.registeredProgramModal()).toBe(false); + expect(service.program()?.welcomeAcknowledgedAt).toBe(timestamp); + }); }); diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts index 4ddfc1451..4df92007b 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts @@ -28,6 +28,7 @@ export class ProgramDetailMainUIInfoService { readonly welcomeAcknowledgementPending = signal(false); readonly registerDateExpired = signal(false); + readonly evaluationDateExpired = signal(false); applyInitProgramQueryParams(): void { this.applyProgramOpenModal("access"); @@ -49,6 +50,8 @@ export class ProgramDetailMainUIInfoService { applyFormatingProgramData(program: Program): void { this.program.set(program); this.registerDateExpired.set(Date.now() > Date.parse(program.datetimeRegistrationEnds)); + const evaluationEnds = Date.parse(program.datetimeEvaluationEnds ?? ""); + this.evaluationDateExpired.set(Number.isFinite(evaluationEnds) && Date.now() > evaluationEnds); this.registeredProgramModal.set(program.isUserMember && program.welcomeAcknowledgedAt === null); } diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignment-scores.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignment-scores.use-case.ts new file mode 100644 index 000000000..6c6e0c0cf --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignment-scores.use-case.ts @@ -0,0 +1,37 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsAssignmentScoreDetail, + ProgramAnalyticsError, +} from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Manager-only detail: принадлежность назначения программе проверяет backend. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerAssignmentScoresUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + execute( + programId: number, + assignmentId: number, + ): Observable> { + return this.repository.getManagerAssignmentScores(programId, assignmentId).pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAnalyticsError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : "network"; + return of(fail({ kind })); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignments.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignments.use-case.spec.ts new file mode 100644 index 000000000..841cbc148 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignments.use-case.spec.ts @@ -0,0 +1,60 @@ +/** @format */ +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { assignment, scoreDetail } from "@domain/program/program-analytics-assignment.fixture"; +import { GetProgramManagerAssignmentsUseCase } from "./get-program-manager-assignments.use-case"; +import { GetProgramManagerAssignmentScoresUseCase } from "./get-program-manager-assignment-scores.use-case"; + +describe("Manager drilldown use cases", () => { + const repository = { getManagerAssignments: vi.fn(), getManagerAssignmentScores: vi.fn() }; + beforeEach(() => { + vi.resetAllMocks(); + TestBed.configureTestingModule({ + providers: [{ provide: ProgramRepositoryPort, useValue: repository }], + }); + }); + + it("возвращает Result и не преобразует данные критериев", async () => { + const list = [assignment()]; + const detail = scoreDetail(); + repository.getManagerAssignments.mockReturnValue(of(list)); + repository.getManagerAssignmentScores.mockReturnValue(of(detail)); + expect( + await firstValueFrom( + TestBed.inject(GetProgramManagerAssignmentsUseCase).execute(12, "completed"), + ), + ).toEqual({ ok: true, value: list }); + expect( + await firstValueFrom( + TestBed.inject(GetProgramManagerAssignmentScoresUseCase).execute(12, 17), + ), + ).toEqual({ ok: true, value: detail }); + expect(repository.getManagerAssignments).toHaveBeenCalledWith(12, "completed"); + expect(repository.getManagerAssignmentScores).toHaveBeenCalledWith(12, 17); + }); + + it.each([ + [401, "unauthorized"], + [403, "forbidden"], + [404, "not_found"], + [500, "network"], + [0, "network"], + ])("status %s -> только domain error %s", async (status, kind) => { + const response = throwError( + () => new HttpErrorResponse({ status: Number(status), error: "private backend text" }), + ); + repository.getManagerAssignments.mockReturnValue(response); + repository.getManagerAssignmentScores.mockReturnValue(response); + for (const result of [ + await firstValueFrom(TestBed.inject(GetProgramManagerAssignmentsUseCase).execute(12, "all")), + await firstValueFrom( + TestBed.inject(GetProgramManagerAssignmentScoresUseCase).execute(12, 17), + ), + ]) { + expect(result).toEqual({ ok: false, error: { kind } }); + expect(JSON.stringify(result)).not.toContain("private backend text"); + } + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignments.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignments.use-case.ts new file mode 100644 index 000000000..1c73f4986 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-assignments.use-case.ts @@ -0,0 +1,38 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsAssignment, + ProgramAnalyticsAssignmentScope, + ProgramAnalyticsError, +} from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Manager-only список: pending включает not_ready, pending и in_progress. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerAssignmentsUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + execute( + programId: number, + scope: ProgramAnalyticsAssignmentScope, + ): Observable> { + return this.repository.getManagerAssignments(programId, scope).pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAnalyticsError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : "network"; + return of(fail({ kind })); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts new file mode 100644 index 000000000..a196e456a --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-attention.use-case.spec.ts @@ -0,0 +1,144 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse, HttpParams } from "@angular/common/http"; +import { ApiService } from "@corelib"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramHttpAdapter } from "@infrastructure/adapters/program/program-http.adapter"; +import { ProgramRepository } from "@infrastructure/repository/program/program.repository"; +import { + participantsPage, + projectsPage, + notSubmittedPage, +} from "@domain/program/program-analytics-attention.fixture"; +import { GetProgramManagerParticipantsWithoutTeamUseCase } from "./get-program-manager-participants-without-team.use-case"; +import { GetProgramManagerProjectsAwaitingEvaluationUseCase } from "./get-program-manager-projects-awaiting-evaluation.use-case"; +import { GetProgramManagerProjectsNotSubmittedUseCase } from "./get-program-manager-projects-not-submitted.use-case"; + +describe("Attention: adapter → repository → use cases", () => { + const api = { get: vi.fn() }; + beforeEach(() => { + api.get.mockReset(); + TestBed.configureTestingModule({ + providers: [ + { provide: ApiService, useValue: api }, + { provide: ProgramRepositoryPort, useClass: ProgramRepository }, + ], + }); + }); + + for (const [method, endpoint, useCase, page] of [ + [ + "getManagerProjectsNotSubmitted", + "projects-not-submitted", + GetProgramManagerProjectsNotSubmittedUseCase, + notSubmittedPage(), + ], + [ + "getManagerParticipantsWithoutTeam", + "participants-without-team", + GetProgramManagerParticipantsWithoutTeamUseCase, + participantsPage(), + ], + [ + "getManagerProjectsAwaitingEvaluation", + "projects-awaiting-evaluation", + GetProgramManagerProjectsAwaitingEvaluationUseCase, + projectsPage(), + ], + ] as const) { + const injectUseCase = () => + TestBed.inject< + | GetProgramManagerParticipantsWithoutTeamUseCase + | GetProgramManagerProjectsAwaitingEvaluationUseCase + | GetProgramManagerProjectsNotSubmittedUseCase + >(useCase); + it(`${endpoint}: forwarding, response passthrough, отсутствие cache`, async () => { + api.get.mockReturnValue(of(page)); + const adapter = TestBed.inject(ProgramHttpAdapter); + const delegate = vi.spyOn(adapter, method); + const service = injectUseCase(); + const query = { search: " Анна ", limit: 25, offset: 25 }; + expect(await firstValueFrom(service.execute(12, query))).toEqual({ + ok: true, + value: page, + }); + await firstValueFrom(service.execute(12, query)); + expect(delegate).toHaveBeenCalledTimes(2); + expect(delegate).toHaveBeenLastCalledWith(12, query); + expect(api.get).toHaveBeenCalledTimes(2); + const [url, params] = api.get.mock.lastCall! as [string, HttpParams]; + expect(url).toBe(`/programs/12/project-analytics/${endpoint}/`); + expect(url).not.toContain("manager-overview"); + expect(params.get("search")).toBe("Анна"); + expect(params.get("limit")).toBe("25"); + expect(params.get("offset")).toBe("25"); + }); + + it(`${endpoint}: default page, blank search absent`, async () => { + api.get.mockReturnValue(of(page)); + await firstValueFrom(injectUseCase().execute(12, { search: " " })); + const params = api.get.mock.lastCall![1] as HttpParams; + expect(params.toString()).toBe("limit=25&offset=0"); + }); + + it.each([ + [401, "unauthorized"], + [403, "forbidden"], + [404, "not_found"], + [0, "network"], + [500, "network"], + ] as const)(`${endpoint}: %s не раскрывает HTTP body`, async (status, kind) => { + api.get.mockReturnValue( + throwError(() => new HttpErrorResponse({ status, error: "secret backend body" })), + ); + expect(await firstValueFrom(injectUseCase().execute(12, {}))).toEqual({ + ok: false, + error: { kind }, + }); + }); + } + + it("not-submitted сохраняет nullable leader/deadline и applicable=false", async () => { + const page = notSubmittedPage({ + submissionDeadline: null, + results: [{ ...notSubmittedPage().results[0], leader: null }], + }); + api.get.mockReturnValueOnce(of(page)); + const service = TestBed.inject(GetProgramManagerProjectsNotSubmittedUseCase); + expect(await firstValueFrom(service.execute(12, {}))).toEqual({ ok: true, value: page }); + const noncompetitive = notSubmittedPage({ + applicable: false, + count: 0, + results: [], + submissionDeadline: null, + submissionOpen: false, + }); + api.get.mockReturnValueOnce(of(noncompetitive)); + expect(await firstValueFrom(service.execute(12, {}))).toEqual({ + ok: true, + value: noncompetitive, + }); + }); + + it("open mode: null assignment counters не превращаются в 0", async () => { + const page = projectsPage({ + mode: "open", + results: [ + { + ...projectsPage().results[0], + assignmentsTotal: null, + assignmentsCompleted: null, + reason: "awaiting_first_evaluation", + reasonLabel: "Ожидает первой оценки", + }, + ], + }); + api.get.mockReturnValue(of(page)); + expect( + await firstValueFrom( + TestBed.inject(GetProgramManagerProjectsAwaitingEvaluationUseCase).execute(12, {}), + ), + ).toEqual({ ok: true, value: page }); + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-overview.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-overview.use-case.spec.ts new file mode 100644 index 000000000..d40bdaa64 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-overview.use-case.spec.ts @@ -0,0 +1,45 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { ProgramAnalyticsOverview } from "@domain/program/program-analytics.model"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { GetProgramManagerOverviewUseCase } from "./get-program-manager-overview.use-case"; + +describe("GetProgramManagerOverviewUseCase", () => { + const repository = { getManagerOverview: vi.fn() }; + let useCase: GetProgramManagerOverviewUseCase; + + beforeEach(() => { + repository.getManagerOverview.mockReset(); + TestBed.configureTestingModule({ + providers: [ + GetProgramManagerOverviewUseCase, + { provide: ProgramRepositoryPort, useValue: repository }, + ], + }); + useCase = TestBed.inject(GetProgramManagerOverviewUseCase); + }); + + it("возвращает manager overview программы", async () => { + const overview = { program: { id: 12, name: "Программа" } } as ProgramAnalyticsOverview; + repository.getManagerOverview.mockReturnValue(of(overview)); + + await expect(firstValueFrom(useCase.execute(12))).resolves.toEqual({ + ok: true, + value: overview, + }); + }); + + it("сохраняет HTTP status для безопасного error state", async () => { + repository.getManagerOverview.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 403 })), + ); + + await expect(firstValueFrom(useCase.execute(12))).resolves.toEqual({ + ok: false, + error: { kind: "manager_overview_error", status: 403 }, + }); + }); +}); diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-overview.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-overview.use-case.ts new file mode 100644 index 000000000..58627e315 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-overview.use-case.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ProgramAnalyticsOverview } from "@domain/program/program-analytics.model"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +export interface ProgramManagerOverviewFailure { + kind: "manager_overview_error"; + status: number; +} + +/** Загружает обезличенную сводку программы, доступную её организатору. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerOverviewUseCase { + private readonly programRepository = inject(ProgramRepositoryPort); + + execute( + programId: number, + ): Observable> { + return this.programRepository.getManagerOverview(programId).pipe( + map(overview => ok(overview)), + catchError((error: unknown) => + of( + fail({ + kind: "manager_overview_error" as const, + status: error instanceof HttpErrorResponse ? error.status : 0, + }), + ), + ), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-team.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-team.use-case.ts new file mode 100644 index 000000000..1c210a90e --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-participants-without-team.use-case.ts @@ -0,0 +1,45 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { ProgramAnalyticsError } from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Страница участников без команды текущей программы, без приватных данных. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerParticipantsWithoutTeamUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + /** Передаёт поиск/страницу и возвращает только безопасную категорию ошибки, не HTTP body. */ + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable< + Result< + ProgramAnalyticsAttentionPage, + ProgramAnalyticsError + > + > { + return this.repository.getManagerParticipantsWithoutTeam(programId, query).pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAnalyticsError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : "network"; + return of(fail({ kind })); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case.ts new file mode 100644 index 000000000..4c2594b0c --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-awaiting-evaluation.use-case.ts @@ -0,0 +1,39 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { ProgramAnalyticsError } from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Страница сданных работ, ожидающих оценивания; режим и причины задаёт backend. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerProjectsAwaitingEvaluationUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + /** Передаёт поиск/страницу и возвращает только безопасную категорию ошибки, не HTTP body. */ + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.repository.getManagerProjectsAwaitingEvaluation(programId, query).pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAnalyticsError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : "network"; + return of(fail({ kind })); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-not-submitted.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-not-submitted.use-case.ts new file mode 100644 index 000000000..fd57e9063 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-manager-projects-not-submitted.use-case.ts @@ -0,0 +1,39 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramAnalyticsNotSubmittedProjectsPage, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { ProgramAnalyticsError } from "@domain/program/program-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +/** Read-only страница несданных проектов; применимость и сроки определяет backend. */ +@Injectable({ providedIn: "root" }) +export class GetProgramManagerProjectsNotSubmittedUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + /** Сохраняет серверные данные и отдаёт контролируемую ошибку без сырого HTTP body. */ + execute( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.repository.getManagerProjectsNotSubmitted(programId, query).pipe( + map(value => ok(value)), + catchError((error: unknown) => { + const status = error instanceof HttpErrorResponse ? error.status : 0; + const kind: ProgramAnalyticsError["kind"] = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : "network"; + return of(fail({ kind })); + }), + ); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.spec.ts new file mode 100644 index 000000000..55bc2765d --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.spec.ts @@ -0,0 +1,518 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, of, Subject, throwError } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramLinkFields } from "@domain/project/program-link-fields.model"; +import { programLinkFields } from "@domain/project/program-link-fields.fixture"; +import { PROGRAM_CASE_FIELD_NAME } from "@domain/program/program-case-field.const"; +import { ProjectAdditionalService } from "./project-additional.service"; + +describe("ProjectAdditionalService canonical fields", () => { + let service: ProjectAdditionalService; + const repo = { getProgramLinkFields: vi.fn(), updateProgramLinkFields: vi.fn() }; + const programRepo = { submitCompettetiveProject: vi.fn() }; + const logger = { error: vi.fn() }; + const caseControl = () => service.getAdditionalForm().get(PROGRAM_CASE_FIELD_NAME)!; + + beforeEach(() => { + repo.getProgramLinkFields.mockReset().mockReturnValue(of(programLinkFields())); + repo.updateProgramLinkFields.mockReset().mockReturnValue(of(undefined)); + programRepo.submitCompettetiveProject.mockReset().mockReturnValue(of({})); + logger.error.mockClear(); + TestBed.configureTestingModule({ + providers: [ + ProjectAdditionalService, + { provide: ProjectProgramRepositoryPort, useValue: repo }, + { provide: ProgramRepositoryPort, useValue: programRepo }, + { provide: LoggerService, useValue: logger }, + ], + }); + service = TestBed.inject(ProjectAdditionalService); + }); + + it("keeps one stable form; null case is empty/required and generic saved/default values survive", () => { + const form = service.getAdditionalForm(); + service.setContext(55, "700", 900); + expect(repo.getProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700); + expect(service.getAdditionalForm()).toBe(form); + expect(caseControl().value).toBe(""); + expect(caseControl().hasError("required")).toBe(true); + expect(form.value).toEqual({ case: "", track: "Y", note: "", agree: false }); + expect(service.activeProgramLinkId()).toBe(700); + }); + + it.each([undefined, "invalid", "-2", "0", "1.5"])( + "invalid/absent query %s uses the explicit partnerProgram fallback", + value => { + service.setContext(55, value, 700); + expect(repo.getProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700); + }, + ); + + it("invalid link without fallback never loads or saves", async () => { + service.setContext(55, "bad"); + expect(service.loadFailed()).toBe(true); + expect(await firstValueFrom(service.save(700, false))).toMatchObject({ ok: false }); + expect(repo.getProgramLinkFields).not.toHaveBeenCalled(); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + }); + + it("loading is not empty success; retry reuses the same link", () => { + const response = new Subject(); + repo.getProgramLinkFields.mockReturnValue(response); + service.setContext(55, 700); + expect(service.pending()).toBe(true); + expect(service.activeProgramLinkId()).toBeNull(); + response.error(new HttpErrorResponse({ status: 500, error: "raw" })); + expect(service.loadFailed()).toBe(true); + repo.getProgramLinkFields.mockReturnValue(of(programLinkFields({ fields: [] }))); + service.retry(); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(service.fieldsState().status).toBe("success"); + expect(service.partnerProgramFields()).toEqual([]); + }); + + it.each([{ projectId: 99 }, { programLinkId: 900 }])( + "rejects mismatched GET identity %j, logs and prevents PUT", + async mismatch => { + repo.getProgramLinkFields.mockReturnValue(of(programLinkFields(mismatch))); + service.setContext(55, 700); + expect(service.activeProgramLinkId()).toBeNull(); + expect(service.loadFailed()).toBe(true); + expect(service.partnerProgramFields()).toEqual([]); + expect(Object.keys(service.getAdditionalForm().controls)).toEqual([]); + expect(logger.error).toHaveBeenCalled(); + await firstValueFrom(service.save(700, false)); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + }, + ); + + it.each([null, undefined, "", " "])( + "draft omits empty case %j without suppressing generic values", + async value => { + service.setContext(55, 700); + caseControl().setValue(value); + expect((await firstValueFrom(service.save(700, false))).ok).toBe(true); + expect(repo.updateProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700, [ + { fieldId: 6, valueText: "Y" }, + { fieldId: 7, valueText: "" }, + { fieldId: 8, valueText: "false" }, + ]); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }, + ); + + it("saved case restores and draft changes A to B without reset", async () => { + const snapshot = programLinkFields(); + snapshot.fields[0].value = "A"; + repo.getProgramLinkFields.mockReturnValue(of(snapshot)); + service.setContext(55, 700); + expect(caseControl().value).toBe("A"); + await firstValueFrom(service.save(700, false)); + expect(repo.updateProgramLinkFields.mock.calls[0][1][0]).toEqual({ + fieldId: 5, + valueText: "A", + }); + caseControl().setValue("B"); + await firstValueFrom(service.save(700, false)); + expect(repo.updateProgramLinkFields.mock.calls[1][1][0]).toEqual({ + fieldId: 5, + valueText: "B", + }); + expect(caseControl().value).toBe("B"); + snapshot.fields[0].value = "B"; + service.retry(); + expect(caseControl().value).toBe("B"); + }); + + it("empty required case blocks BOTH PUT and submit and becomes touched", async () => { + service.setContext(55, 700); + expect((await firstValueFrom(service.save(700, true))).ok).toBe(false); + expect(caseControl().touched).toBe(true); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }); + + it("PUT finishes before submit; both use active A even if fallback B exists", () => { + service.setContext(55, 700, 900); + const put = new Subject(); + repo.updateProgramLinkFields.mockReturnValue(put); + caseControl().setValue("B"); + service.save(700, true).subscribe(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + put.next(); + put.complete(); + expect(repo.updateProgramLinkFields.mock.calls[0][0]).toBe(700); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); + expect(service.submitted()).toBe(true); + expect(service.getAdditionalForm().disabled).toBe(true); + }); + + it("submitted snapshot disables controls and never PUTs", async () => { + const snapshot = programLinkFields({ submitted: true }); + snapshot.fields[0].value = "B"; + repo.getProgramLinkFields.mockReturnValue(of(snapshot)); + service.setContext(55, 700); + expect(caseControl().value).toBe("B"); + expect(caseControl().disabled).toBe(true); + service.setBooleanValue("agree", true); + service.toggleAdditionalFormValues("checkbox", "agree"); + expect(service.getAdditionalForm().get("agree")?.value).toBe(false); + await firstValueFrom(service.save(700, false)); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + }); + + it.each([0, 400, 500])( + "unknown PUT status %s keeps edits and displays controlled text", + async status => { + service.setContext(55, 700); + caseControl().setValue("A"); + repo.updateProgramLinkFields.mockReturnValue( + throwError(() => new HttpErrorResponse({ status, error: "raw sensitive error" })), + ); + await firstValueFrom(service.save(700, false)); + expect(caseControl().value).toBe("A"); + expect(service.saveError()).toBe( + status === 0 + ? "Не удалось сохранить данные. Проверьте подключение и попробуйте ещё раз." + : status === 500 + ? "Не удалось сохранить дополнительные сведения. Попробуйте ещё раз позже." + : "Не удалось сохранить дополнительные сведения. Попробуйте ещё раз.", + ); + expect(service.saveError()).not.toContain("raw"); + expect(service.isSend$().status).toBe("failure"); + }, + ); + + it("backend missing-case error stays visible on the control", async () => { + service.setContext(55, 700); + caseControl().setValue("A"); + programRepo.submitCompettetiveProject.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { detail: "Выберите кейс перед сдачей проекта." }, + }), + ), + ); + await firstValueFrom(service.save(700, true)); + expect(caseControl().touched).toBe(true); + expect(caseControl().hasError("caseBackend")).toBe(true); + expect(service.caseError()).toBe("Выберите кейс перед сдачей проекта."); + expect(service.submitted()).toBe(false); + }); + + it.each(["put", "submit"])( + "stale case from %s refreshes options without losing generic edits", + async source => { + service.setContext(55, 700); + caseControl().setValue("A"); + service.getAdditionalForm().get("note")?.setValue("unsaved note"); + const fresh = programLinkFields(); + fresh.fields[0].options = ["B"]; + repo.getProgramLinkFields.mockReturnValue(of(fresh)); + const error = new HttpErrorResponse({ + status: 400, + error: { detail: "Выбранный кейс больше недоступен. Выберите актуальный кейс." }, + }); + (source === "put" + ? repo.updateProgramLinkFields + : programRepo.submitCompettetiveProject + ).mockReturnValue(throwError(() => error)); + await firstValueFrom(service.save(700, true)); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(service.partnerProgramFields()[0].options).toEqual(["B"]); + expect(caseControl().value).toBe(""); + expect(caseControl().invalid).toBe(true); + expect(service.caseError()).toBe( + "Выбранный кейс больше недоступен. Выберите актуальный кейс.", + ); + expect(service.getAdditionalForm().get("note")?.value).toBe("unsaved note"); + caseControl().setValue("B"); + expect(service.caseError()).toBeNull(); + }, + ); + + it("switching relation cancels old GET and never applies B fields to A", () => { + const old = new Subject(); + repo.getProgramLinkFields + .mockReturnValueOnce(old) + .mockReturnValue(of(programLinkFields({ programLinkId: 900, fields: [] }))); + service.setContext(55, 700); + service.setContext(55, 900); + expect(old.observed).toBe(false); + old.next(programLinkFields()); + expect(service.activeProgramLinkId()).toBe(900); + expect(service.partnerProgramFields()).toEqual([]); + }); + + it("route mismatch immediately cancels pending PUT and prevents subsequent submit", () => { + service.setContext(55, 700); + caseControl().setValue("A"); + const put = new Subject(); + repo.updateProgramLinkFields.mockReturnValue(put); + service.save(700, true).subscribe(); + service.setContext(null, 900); + expect(put.observed).toBe(false); + expect(service.activeProgramLinkId()).toBeNull(); + put.next(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }); + + it("destroy unsubscribes pending GET", () => { + const request = new Subject(); + repo.getProgramLinkFields.mockReturnValue(request); + service.setContext(55, 700); + TestBed.resetTestingModule(); + expect(request.observed).toBe(false); + }); + + it("clears all metadata with the old active link and ignores stale A after loading B", () => { + const old = new Subject(); + const next = new Subject(); + repo.getProgramLinkFields.mockReturnValueOnce(old).mockReturnValueOnce(next); + service.setContext(55, 700, 900); + old.next(programLinkFields()); + expect(service.isCompetitive()).toBe(true); + expect(service.canSubmit()).toBe(true); + expect(service.submissionOpen()).toBe(true); + expect(service.submissionDeadline()).toBe("2026-10-01T18:00:00Z"); + service.setContext(55, 900); + expect(old.observed).toBe(false); + expect(service.activeProgramLinkId()).toBeNull(); + expect(service.isCompetitive()).toBe(false); + expect(service.submissionOpen()).toBe(false); + expect(service.submissionDeadline()).toBeNull(); + expect(service.canSubmit()).toBe(false); + expect(service.submitted()).toBe(false); + next.next( + programLinkFields({ + programLinkId: 900, + isCompetitive: false, + canSubmit: false, + submissionDeadline: null, + }), + ); + old.next(programLinkFields()); + expect(service.activeProgramLinkId()).toBe(900); + expect(service.isCompetitive()).toBe(false); + expect(service.canSubmit()).toBe(false); + expect(service.submissionDeadline()).toBeNull(); + expect(service.submissionOpen()).toBe(true); + }); + + it.each([ + [{ submitted: true, canSubmit: false }, "already_submitted"], + [{ isCompetitive: false, canSubmit: false }, "not_competitive"], + [{ submissionOpen: false, canSubmit: false }, "submission_closed"], + [{ submissionOpen: true, canSubmit: false }, "context"], + ] as const)( + "save submit=true blocks canonical state %j before any HTTP", + async (metadata, kind) => { + repo.getProgramLinkFields.mockReturnValue(of(programLinkFields(metadata))); + service.setContext(55, 700); + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind }, + }); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }, + ); + + it("draft PUT does not require canSubmit and still omits empty case", async () => { + repo.getProgramLinkFields.mockReturnValue( + of(programLinkFields({ submissionOpen: false, canSubmit: false })), + ); + service.setContext(55, 700); + expect((await firstValueFrom(service.save(700, false))).ok).toBe(true); + expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce(); + expect(repo.updateProgramLinkFields.mock.calls[0][1]).not.toContainEqual( + expect.objectContaining({ fieldId: 5 }), + ); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }); + + it("direct submit rejects unloaded and mismatched link contexts even with a valid case", async () => { + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind: "context" }, + }); + service.setContext(55, 700); + caseControl().setValue("A"); + expect(await firstValueFrom(service.save(900, true))).toMatchObject({ + ok: false, + error: { kind: "context" }, + }); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }); + + it("backend deadline race refreshes eligibility, preserves edits and blocks another PUT/submit", async () => { + service.setContext(55, 700); + caseControl().setValue("A"); + service.getAdditionalForm().get("note")?.setValue("local note"); + const reload = new Subject(); + repo.getProgramLinkFields.mockReturnValue(reload); + programRepo.submitCompettetiveProject.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { detail: "Срок подачи проектов в программу завершён." }, + }), + ), + ); + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind: "submission_closed" }, + }); + expect(service.saveError()).toBe("Срок подачи проектов в программу завершён."); + expect(service.isSend$().status).toBe("failure"); + expect(service.submitted()).toBe(false); + expect(caseControl().value).toBe("A"); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(service.activeProgramLinkId()).toBeNull(); + const closed = programLinkFields({ submissionOpen: false, canSubmit: false }); + closed.fields[0].value = "B"; + reload.next(closed); + reload.complete(); + expect(service.activeProgramLinkId()).toBe(700); + expect(service.submissionOpen()).toBe(false); + expect(service.canSubmit()).toBe(false); + expect(caseControl().value).toBe("A"); + expect(service.getAdditionalForm().get("note")?.value).toBe("local note"); + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind: "submission_closed" }, + }); + expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + }); + + it("PUT after another tab submits refetches saved values and never calls submit or retries PUT", async () => { + service.setContext(55, 700); + expect(service.submitted()).toBe(false); + expect(service.canSubmit()).toBe(true); + caseControl().setValue("A"); + const reload = new Subject(); + repo.getProgramLinkFields.mockReturnValue(reload); + const detail = "Нельзя изменять значения полей программы после сдачи проекта на проверку."; + repo.updateProgramLinkFields.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 400, error: { detail } })), + ); + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind: "already_submitted" }, + }); + expect(service.saveError()).toBe("Проект уже был сдан на проверку."); + expect(service.saveError()).not.toBe(detail); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + expect(service.activeProgramLinkId()).toBeNull(); + const submitted = programLinkFields({ submitted: true, canSubmit: false }); + submitted.fields[0].value = "B"; + reload.next(submitted); + reload.complete(); + expect(service.activeProgramLinkId()).toBe(700); + expect(service.submitted()).toBe(true); + expect(service.canSubmit()).toBe(false); + expect(caseControl().value).toBe("B"); + expect(service.getAdditionalForm().disabled).toBe(true); + expect(service.isSend$().status).toBe("failure"); + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind: "already_submitted" }, + }); + expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + }); + + it("already-submitted race refetches A, shows the saved case read-only and forbids another PUT/submit", async () => { + service.setContext(55, 700); + caseControl().setValue("A"); + const reload = new Subject(); + repo.getProgramLinkFields.mockReturnValue(reload); + programRepo.submitCompettetiveProject.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { detail: "Проект уже был сдан на проверку." }, + }), + ), + ); + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind: "already_submitted" }, + }); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(service.activeProgramLinkId()).toBeNull(); + expect(service.canSubmit()).toBe(false); + const snapshot = programLinkFields({ submitted: true, canSubmit: false }); + snapshot.fields[0].value = "B"; + reload.next(snapshot); + expect(caseControl().value).toBe("B"); + expect(service.getAdditionalForm().disabled).toBe(true); + expect(service.submitted()).toBe(true); + await firstValueFrom(service.save(700, true)); + expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledTimes(1); + expect(service.isSend$().status).toBe("failure"); + }); + + it("not-competitive backend guard refreshes metadata and preserves edits without automatic submit", async () => { + service.setContext(55, 700); + caseControl().setValue("A"); + repo.getProgramLinkFields.mockReturnValue( + of(programLinkFields({ isCompetitive: false, canSubmit: false })), + ); + programRepo.submitCompettetiveProject.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { detail: "Программа не является конкурсной." }, + }), + ), + ); + expect(await firstValueFrom(service.save(700, true))).toMatchObject({ + ok: false, + error: { kind: "not_competitive" }, + }); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(service.isCompetitive()).toBe(false); + expect(service.canSubmit()).toBe(false); + expect(caseControl().value).toBe("A"); + await firstValueFrom(service.save(700, true)); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledTimes(1); + expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1); + }); + + it("cancels an in-flight submit when the route link changes; its error cannot refresh B", () => { + service.setContext(55, 700); + caseControl().setValue("A"); + const submission = new Subject(); + programRepo.submitCompettetiveProject.mockReturnValue(submission); + service.save(700, true).subscribe(); + repo.getProgramLinkFields.mockReturnValue( + of(programLinkFields({ programLinkId: 900, isCompetitive: false, canSubmit: false })), + ); + service.setContext(55, 900); + expect(submission.observed).toBe(false); + submission.error( + new HttpErrorResponse({ status: 400, error: { detail: "Проект уже был сдан на проверку." } }), + ); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [900]]); + expect(service.activeProgramLinkId()).toBe(900); + expect(service.isCompetitive()).toBe(false); + expect(service.saveError()).toBeNull(); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts index fd8f5ef70..7bb7b9a43 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts @@ -1,165 +1,328 @@ /** @format */ -import { inject, Injectable, signal } from "@angular/core"; +import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { defer, map, Observable, of, Subject, Subscription, switchMap, takeUntil, tap } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; import { - PartnerProgramFields, - PartnerProgramFieldsValues, - ProjectNewAdditionalProgramFields, -} from "@domain/program/partner-program-fields.model"; -import { Observable } from "rxjs"; -import { SendProjectAdditionalFieldsUseCase } from "../../use-cases/send-project-additional-fields.use-case"; -import { SubmitCompetitiveProjectUseCase } from "../../use-cases/submit-competitive-project.use-case"; + ProgramLinkField, + ProgramLinkFields, + ProgramLinkFieldsError, +} from "@domain/project/program-link-fields.model"; +import { PROGRAM_CASE_FIELD_NAME } from "@domain/program/program-case-field.const"; +import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; import { AsyncState, failure, initial, loading, success } from "@domain/shared/async-state"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { GetProgramLinkFieldsUseCase } from "../../use-cases/get-program-link-fields.use-case"; +import { UpdateProgramLinkFieldsUseCase } from "../../use-cases/update-program-link-fields.use-case"; +import { SubmitCompetitiveProjectUseCase } from "../../use-cases/submit-competitive-project.use-case"; +import { + mapProgramLinkFieldsError, + programLinkFieldsErrorMessage, +} from "../../program-link-fields-error"; -/** Сервис дополнительных полей проекта в партнерской программе. */ +/** Parse positive integer IDs only, never a project/program ID in place of a relation. */ +function linkId(value: unknown): number | null { + if (typeof value !== "string" && typeof value !== "number") return null; + if (!/^[1-9]\d*$/.test(String(value))) return null; + const id = Number(value); + return Number.isSafeInteger(id) ? id : null; +} + +/** Owns the canonical relation context, stable form and cancellable fields lifecycle. */ @Injectable() export class ProjectAdditionalService { - private additionalForm!: FormGroup; - readonly partnerProgramFields = signal([]); - private partnerProgramFieldsValues: PartnerProgramFieldsValues[] = []; - private readonly fb = inject(FormBuilder); - private readonly sendProjectAdditionalFieldsUseCase = inject(SendProjectAdditionalFieldsUseCase); - private readonly submitCompetitiveProjectUseCase = inject(SubmitCompetitiveProjectUseCase); + private readonly getFields = inject(GetProgramLinkFieldsUseCase); + private readonly updateFields = inject(UpdateProgramLinkFieldsUseCase); + private readonly submitProject = inject(SubmitCompetitiveProjectUseCase); + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + private readonly additionalForm = this.fb.group({}) as FormGroup; + private readonly contextChanges = new Subject(); + private readonly formChanges = new Subject(); + private loadSubscription?: Subscription; + private context: { projectId: number; programLinkId: number } | null = null; + private contextKey = ""; + readonly partnerProgramFields = signal([]); + readonly fieldsState = signal>(initial()); + readonly hasProgramLink = signal(false); + /** Exposed only after GET verifies both the relation and the route project. */ + readonly activeProgramLinkId = signal(null); + /** Never expose metadata from a previous, loading or unverified relation. */ + private readonly activeSnapshot = computed(() => { + const state = this.fieldsState(); + return state.status === "success" && state.data.programLinkId === this.activeProgramLinkId() + ? state.data + : null; + }); + readonly isCompetitive = computed(() => this.activeSnapshot()?.isCompetitive === true); + readonly submissionOpen = computed(() => this.activeSnapshot()?.submissionOpen === true); + readonly submissionDeadline = computed(() => this.activeSnapshot()?.submissionDeadline ?? null); + readonly canSubmit = computed(() => this.activeSnapshot()?.canSubmit === true); + readonly submitted = signal(false); + readonly pending = computed(() => this.fieldsState().status === "loading"); + readonly loadFailed = computed(() => this.fieldsState().status === "failure"); + readonly saveError = signal(null); + readonly caseError = signal(null); readonly isSend$ = signal>(initial()); - readonly errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>( null, ); constructor() { - // Инициализируем пустую форму - this.additionalForm = this.fb.group({}); + this.destroyRef.onDestroy(() => { + this.loadSubscription?.unsubscribe(); + this.contextChanges.next(); + this.contextChanges.complete(); + this.formChanges.next(); + this.formChanges.complete(); + }); } - public getAdditionalForm(): FormGroup { + getAdditionalForm(): FormGroup { return this.additionalForm; } - public getPartnerProgramFieldsValues(): PartnerProgramFieldsValues[] { - return this.partnerProgramFieldsValues; + /** Query ID wins over the legacy single-link fallback. Changes cancel old work immediately. */ + setContext(projectId: number | null, routeLink: unknown, fallbackLink?: number): void { + const activeLink = linkId(routeLink) ?? linkId(fallbackLink); + const key = JSON.stringify([projectId, activeLink, activeLink === null ? routeLink : null]); + if (key === this.contextKey) return; + this.contextKey = key; + this.loadSubscription?.unsubscribe(); + this.contextChanges.next(); + this.context = projectId && activeLink ? { projectId, programLinkId: activeLink } : null; + this.activeProgramLinkId.set(null); + this.hasProgramLink.set(activeLink !== null || !!routeLink); + this.submitted.set(false); + this.partnerProgramFields.set([]); + this.clearForm(); + this.saveError.set(null); + this.caseError.set(null); + this.isSend$.set(initial()); + this.fieldsState.set(initial()); + if (this.context) this.loadFields(); + else if (projectId && routeLink) this.fieldsState.set(failure("invalid_link")); + else if (!projectId && this.hasProgramLink()) this.fieldsState.set(loading()); } - public initializeAdditionalForm( - fields: PartnerProgramFields[], - values: PartnerProgramFieldsValues[] = [], - clearValues = false, - ): void { - this.partnerProgramFields.set(fields); - this.partnerProgramFieldsValues = clearValues ? [] : values; - - // Создаем новую пустую форму - this.additionalForm = this.fb.group({}); - - // Добавляем контролы для каждого поля - this.partnerProgramFields().forEach(field => { - this.getInitialValue(field, values); - const validators = field.isRequired ? [Validators.required] : []; - const initialValue = this.getInitialValue(field, values); - - this.additionalForm.addControl(field.name, new FormControl(initialValue, validators)); + retry(): void { + if (this.context) this.loadFields(this.partnerProgramFields().length > 0); + } - // Добавляем дополнительную валидацию по типу поля - this.addFieldTypeValidators(field); - }); + private clearForm(): void { + this.formChanges.next(); + for (const name of Object.keys(this.additionalForm.controls)) { + this.additionalForm.removeControl(name, { emitEvent: false }); + } + this.additionalForm.enable({ emitEvent: false }); + } - // Применяем валидацию ко всей форме - this.additionalForm.updateValueAndValidity(); + private loadFields(preserveEdits = false): void { + const context = this.context; + if (!context) return; + this.loadSubscription?.unsubscribe(); + this.activeProgramLinkId.set(null); + this.fieldsState.set(loading()); + const editedValues = preserveEdits ? this.additionalForm.getRawValue() : null; + this.loadSubscription = this.getFields + .execute(context.programLinkId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + if (this.context !== context) return; + if (!result.ok) { + this.logger.error("Cannot load program-link fields", result.error.cause); + this.fieldsState.set(failure("load_error")); + return; + } + const snapshot = result.value; + if ( + snapshot.projectId !== context.projectId || + snapshot.programLinkId !== context.programLinkId + ) { + this.logger.error("Program-link fields context mismatch", { + expected: context, + receivedProjectId: snapshot.projectId, + receivedLinkId: snapshot.programLinkId, + }); + this.fieldsState.set(failure("context_mismatch")); + this.partnerProgramFields.set([]); + this.clearForm(); + return; + } + this.initializeAdditionalForm(snapshot, snapshot.submitted ? null : editedValues); + this.activeProgramLinkId.set(context.programLinkId); + this.fieldsState.set(success(snapshot)); + }); } - public toggleAdditionalFormValues( - fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", - fieldName: string, + private initializeAdditionalForm( + snapshot: ProgramLinkFields, + edits: Record | null, ): void { - if (fieldType === "checkbox" || fieldType === "radio") { - const control = this.additionalForm.get(fieldName); - if (control) { - control.setValue(!control.value); + this.clearForm(); + this.partnerProgramFields.set(snapshot.fields); + this.submitted.set(snapshot.submitted); + for (const field of snapshot.fields) { + const isCase = field.name === PROGRAM_CASE_FIELD_NAME; + let value = edits && field.name in edits ? edits[field.name] : field.value; + if (field.fieldType === "checkbox" || field.fieldType === "radio") { + value = + value === true || (typeof value === "string" && value.trim().toLowerCase() === "true"); + } else value ??= ""; + if (isCase && !snapshot.submitted && value !== "" && !field.options.includes(String(value))) { + value = ""; + this.caseError.set("Выбранный кейс больше недоступен. Выберите актуальный кейс."); } + const validators = field.isRequired || isCase ? [Validators.required] : []; + if (isCase) + validators.push(control => { + if (typeof control.value !== "string" || !control.value.trim()) return { required: true }; + return field.options.includes(control.value) ? null : { caseUnavailable: true }; + }); + if (field.fieldType === "text") validators.push(Validators.maxLength(500)); + if (field.fieldType === "textarea") validators.push(Validators.maxLength(300)); + const control = new FormControl(value, validators); + this.additionalForm.addControl(field.name, control, { emitEvent: false }); + if (isCase) + control.valueChanges + .pipe(takeUntil(this.formChanges), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.caseError.set(null); + this.saveError.set(null); + }); } + if (snapshot.submitted) this.additionalForm.disable({ emitEvent: false }); + this.additionalForm.updateValueAndValidity({ emitEvent: false }); } - public validateRequiredFields(): boolean { - this.additionalForm.updateValueAndValidity(); - this.partnerProgramFields() - .filter(f => f.isRequired) - .forEach(f => this.additionalForm.get(f.name)?.markAsTouched()); - - return this.partnerProgramFields() - .filter(f => f.isRequired) - .some(f => this.additionalForm.get(f.name)?.invalid); + toggleAdditionalFormValues(fieldType: string, fieldName: string): void { + if (this.submitted()) return; + if (fieldType === "checkbox" || fieldType === "radio") { + const control = this.additionalForm.get(fieldName); + control?.setValue(!control.value); + } } - public prepareFieldsForSubmit(): void { - this.partnerProgramFields() - .filter(f => f.isRequired) - .forEach(f => { - const ctrl = this.additionalForm.get(f.name); - if (ctrl && ctrl.value) { - ctrl.clearValidators(); - ctrl.updateValueAndValidity({ emitEvent: false }); - } - }); + setBooleanValue(fieldName: string, value: boolean): void { + if (!this.submitted()) this.additionalForm.get(fieldName)?.setValue(value); } - public sendAdditionalFieldsValues(projectId: number): Observable { - this.isSend$.set(loading()); - const newFieldsFormValues: ProjectNewAdditionalProgramFields[] = []; + validateRequiredFields(): boolean { + this.additionalForm.markAllAsTouched(); + this.additionalForm.updateValueAndValidity(); + return this.additionalForm.invalid; + } - this.partnerProgramFields().forEach((field: PartnerProgramFields) => { - const fieldValue = this.additionalForm.get(field.name)?.value; - newFieldsFormValues.push( - ProjectNewAdditionalProgramFields.fromField(field, String(fieldValue)), - ); + /** Empty case is omitted from partial draft PUT; generic serialization stays unchanged. */ + private valuesForSave(): ProjectNewAdditionalProgramFields[] { + return this.partnerProgramFields().flatMap(field => { + const value = this.additionalForm.get(field.name)?.value; + if (field.name === PROGRAM_CASE_FIELD_NAME && (value == null || String(value).trim() === "")) + return []; + return [ProjectNewAdditionalProgramFields.fromField(field, String(value))]; }); - - return this.sendProjectAdditionalFieldsUseCase.execute(projectId, newFieldsFormValues); } - public submitCompettetiveProject(relationId: number): Observable { - return this.submitCompetitiveProjectUseCase.execute(relationId); + /** PUT and optional submit use the same verified link, cancelled on route context changes. */ + save(programLinkId: number, submit: boolean): Observable> { + return defer(() => { + if ( + this.activeProgramLinkId() !== programLinkId || + this.fieldsState().status !== "success" || + this.isSend$().status === "loading" + ) { + return of(fail({ kind: "context" })); + } + const blocked = this.saveBlockedReason(submit); + if (blocked) { + const error: ProgramLinkFieldsError = { kind: blocked }; + this.saveError.set(programLinkFieldsErrorMessage(error)); + this.isSend$.set(failure(blocked)); + return of(fail(error)); + } + if (submit && this.validateRequiredFields()) { + return of( + fail({ + kind: this.additionalForm.get(PROGRAM_CASE_FIELD_NAME)?.invalid + ? "case_required" + : "unknown", + }), + ); + } + this.saveError.set(null); + this.caseError.set(null); + this.isSend$.set(loading()); + return this.updateFields.execute(programLinkId, this.valuesForSave()).pipe( + switchMap(result => { + if (!result.ok || !submit) return of(result); + return this.submitProject + .execute(programLinkId) + .pipe( + map(submission => + submission.ok + ? ok(undefined) + : fail(mapProgramLinkFieldsError(submission.error.cause)), + ), + ); + }), + tap(result => { + if (!result.ok) { + this.logger.error("Cannot save/submit program-link fields", result.error.cause); + this.isSend$.set(failure(result.error.kind)); + const message = programLinkFieldsErrorMessage(result.error); + this.saveError.set(message); + if (result.error.kind === "case_required" || result.error.kind === "case_unavailable") { + this.caseError.set(message); + const control = this.additionalForm.get(PROGRAM_CASE_FIELD_NAME); + control?.setErrors({ ...(control.errors ?? {}), caseBackend: true }); + control?.markAsTouched(); + if (result.error.kind === "case_unavailable") this.loadFields(true); + } + if ( + result.error.kind === "submission_closed" || + result.error.kind === "already_submitted" || + result.error.kind === "not_competitive" + ) { + this.loadFields(true); + } + } else { + this.isSend$.set(success(undefined)); + if (submit) { + this.submitted.set(true); + // A successful submit acknowledgement freezes this link immediately. + // Eligibility before the request always comes from canonical GET. + const snapshot = this.activeSnapshot(); + if (snapshot) + this.fieldsState.set(success({ ...snapshot, submitted: true, canSubmit: false })); + this.additionalForm.disable({ emitEvent: false }); + } + } + }), + takeUntil(this.contextChanges), + takeUntilDestroyed(this.destroyRef), + ); + }); } - public resetSendingState(): void { - this.isSend$.set(initial()); + private saveBlockedReason(submit: boolean): ProgramLinkFieldsError["kind"] | null { + if (this.submitted()) return "already_submitted"; + if (!submit) return null; + if (!this.isCompetitive()) return "not_competitive"; + if (!this.canSubmit()) return this.submissionOpen() ? "context" : "submission_closed"; + return null; } - public setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { + setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { this.errorAssignProjectToProgramModalMessage.set(error); this.isSend$.set(failure("assign_error")); } - public clearAssignProjectToProgramError(): void { + clearAssignProjectToProgramError(): void { this.errorAssignProjectToProgramModalMessage.set(null); this.isSend$.set(initial()); } - - private getInitialValue(field: PartnerProgramFields, values: PartnerProgramFieldsValues[]): any { - const saved = values.find(v => v.fieldName === field.name); - if (!saved) { - return field.fieldType === "checkbox" || field.fieldType === "radio" ? false : ""; - } - - const text = saved.value.trim().toLowerCase(); - if (field.fieldType === "checkbox" || field.fieldType === "radio") { - return text === "true"; - } - return saved.value; - } - - private addFieldTypeValidators(field: PartnerProgramFields): void { - const control = this.additionalForm.get(field.name); - if (!control) return; - - switch (field.fieldType) { - case "text": - control.addValidators([Validators.maxLength(500)]); - break; - case "textarea": - control.addValidators([Validators.maxLength(300)]); - break; - } - } } diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.spec.ts new file mode 100644 index 000000000..684b47ad2 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.spec.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { FormArray, FormBuilder, FormControl } from "@angular/forms"; +import { ProjectContactsService } from "./project-contacts.service"; +import { ProjectFormService } from "./project-form.service"; + +describe("ProjectContactsService", () => { + let service: ProjectContactsService; + let links: FormArray; + + beforeEach(() => { + const fb = new FormBuilder(); + const form = fb.group({ links: fb.array([]), link: [""] }); + links = form.get("links") as FormArray; + + TestBed.configureTestingModule({ + providers: [ + ProjectContactsService, + { + provide: ProjectFormService, + useValue: { getForm: () => form, editIndex: signal(null) }, + }, + ], + }); + + service = TestBed.inject(ProjectContactsService); + }); + + it("adds visible independent controls for multiple project contact links", () => { + service.addLink(links); + service.addLink(links); + + links.at(0).setValue("https://t.me/procollab"); + links.at(1).setValue("https://vk.com/procollab"); + + expect(service.hasLinks()).toBe(true); + expect(service.linkControls()).toEqual(links.controls); + expect(service.linkControls().every(control => control instanceof FormControl)).toBe(true); + expect(links.getRawValue()).toEqual(["https://t.me/procollab", "https://vk.com/procollab"]); + }); + + it("keeps the reactive controls snapshot synchronized across remove and add", () => { + service.addLink(links); + service.addLink(links); + + service.removeLink(0, links); + expect(service.linkControls()).toEqual([links.at(0)]); + + service.removeLink(0, links); + expect(service.linkControls()).toEqual([]); + expect(service.hasLinks()).toBe(false); + + service.addLink(links); + expect(service.linkControls()).toEqual([links.at(0)]); + expect(service.hasLinks()).toBe(true); + }); + + it("synchronizes existing links without changing their values", () => { + links.push(new FormBuilder().nonNullable.control("https://example.com")); + + service.syncLinksItems(links); + + expect(service.linkControls()).toEqual([links.at(0)]); + expect(links.getRawValue()).toEqual(["https://example.com"]); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts index 51e5f44fc..90a6a8019 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts @@ -11,29 +11,14 @@ import { ProjectFormService } from "./project-form.service"; export class ProjectContactsService { private readonly fb = inject(FormBuilder); private readonly projectFormService = inject(ProjectFormService); - public readonly linksItems = signal([]); - private initialized = false; - - private initializeLinksItems(linksFormArray: FormArray): void { - if (this.initialized) return; - - if (linksFormArray && linksFormArray.length > 0) { - // Синхронизируем сигнал с данными из FormArray - this.linksItems.set(linksFormArray.value); - } - this.initialized = true; - } + public readonly linkControls = signal[]>([]); public syncLinksItems(linksFormArray: FormArray): void { - if (linksFormArray && linksFormArray.length > 0) { - this.linksItems.set(linksFormArray.value); - } else { - this.linksItems.set([]); - } - this.initialized = true; + // Project links are scalar FormControls in the source FormArray. + this.linkControls.set([...linksFormArray.controls] as FormControl[]); } - readonly hasLinks = computed(() => this.linksItems().length > 0); + readonly hasLinks = computed(() => this.linkControls().length > 0); private get projectForm(): FormGroup { return this.projectFormService.getForm(); @@ -48,15 +33,11 @@ export class ProjectContactsService { } public addLink(linksFormArray: FormArray): void { - this.initializeLinksItems(linksFormArray); - linksFormArray.push(this.fb.control("", Validators.required)); + linksFormArray.push(this.fb.nonNullable.control("", Validators.required)); this.syncLinksItems(linksFormArray); } public editLink(index: number, linksFormArray: FormArray, projectForm: FormGroup): void { - // Инициализируем сигнал при необходимости - this.initializeLinksItems(linksFormArray); - // Используем данные из FormArray как источник истины const source = linksFormArray.value[index]; @@ -85,7 +66,6 @@ export class ProjectContactsService { } public reset(): void { - this.linksItems.set([]); - this.initialized = false; + this.linkControls.set([]); } } diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts index 28a73c819..7557db54d 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts @@ -31,6 +31,17 @@ describe("project-form.factory", () => { expect(linkControl?.valid).toBe(true); }); + it("не принимает произвольный регион нового проекта", () => { + const form = createProjectForm(fb); + const region = form.get("region")!; + + region.setValue("Москва"); + expect(region.valid).toBe(true); + + region.setValue("Миксва"); + expect(region.hasError("canonicalRegion")).toBe(true); + }); + it("создаёт группу достижения с fallback id и валидацией года", () => { const achievement = createProjectAchievementGroup( fb, diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts index 896c016e3..8374ae843 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts @@ -1,7 +1,14 @@ /** @format */ -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; +import { + AbstractControl, + FormBuilder, + FormGroup, + ValidationErrors, + Validators, +} from "@angular/forms"; import { Project } from "@domain/project/project.model"; +import { findCanonicalRussianRegion } from "@core/consts/lists/russian-regions-list.const"; type ProjectAchievement = Project["achievements"][number]; @@ -9,7 +16,7 @@ export function createProjectForm(fb: FormBuilder): FormGroup { return fb.group({ imageAddress: [""], name: ["", [Validators.required]], - region: ["", [Validators.required]], + region: ["", [Validators.required, projectRegionValidator()]], implementationDeadline: [null], trl: [null], links: fb.array([]), @@ -29,6 +36,19 @@ export function createProjectForm(fb: FormBuilder): FormGroup { }); } +export function projectRegionValidator(legacyValue = "") { + const normalizedLegacy = legacyValue.trim(); + + return (control: AbstractControl): ValidationErrors | null => { + const value = typeof control.value === "string" ? control.value.trim() : ""; + if (!value) return null; + if (findCanonicalRussianRegion(value)) return null; + if (normalizedLegacy && value === normalizedLegacy) return null; + + return { canonicalRegion: true }; + }; +} + export function createProjectAchievementGroup( fb: FormBuilder, achievement: Partial, diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts index c96eb9c66..8bfc91dc5 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.spec.ts @@ -87,4 +87,24 @@ describe("ProjectFormService", () => { expect(service.achievements.length).toBe(1); expect(service.achievements.at(0).get("title")?.value).toBe("Second"); }); + + it("нормализует безопасные различия регистра и пробелов", () => { + const project = Project.default(); + project.region = " мОскВа "; + + service.initializeProjectData(project); + + expect(service.region?.value).toBe("Москва"); + expect(service.region?.valid).toBe(true); + }); + + it("сохраняет неизвестное legacy-значение без потери при открытии формы", () => { + const project = Project.default(); + project.region = "Миксва"; + + service.initializeProjectData(project); + + expect(service.region?.value).toBe("Миксва"); + expect(service.region?.valid).toBe(true); + }); }); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts index f6347df34..c99ace5c9 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts @@ -12,8 +12,13 @@ import { import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; import { stripNullish } from "@utils/stripNull"; import { Project } from "@domain/project/project.model"; -import { createProjectAchievementGroup, createProjectForm } from "./project-form.factory"; +import { + createProjectAchievementGroup, + createProjectForm, + projectRegionValidator, +} from "./project-form.factory"; import { ProjectFormAutosaveService } from "./project-form-autosave.service"; +import { findCanonicalRussianRegion } from "@core/consts/lists/russian-regions-list.const"; /** Управляет основной формой проекта и формой дополнительных полей партнерской программы. */ @Injectable({ providedIn: "root" }) export class ProjectFormService { @@ -47,11 +52,18 @@ export class ProjectFormService { } public initializeProjectData(project: Project): void { + const rawRegion = typeof project.region === "string" ? project.region.trim() : ""; + const canonicalRegion = findCanonicalRussianRegion(rawRegion); + this.region?.setValidators([ + Validators.required, + projectRegionValidator(canonicalRegion ? "" : rawRegion), + ]); + // Заполняем простые поля this.projectForm.patchValue({ imageAddress: project.imageAddress, name: project.name, - region: project.region, + region: canonicalRegion ?? rawRegion, industryId: project.industry, description: project.description, implementationDeadline: project.implementationDeadline ?? null, @@ -63,6 +75,7 @@ export class ProjectFormService { coverImageAddress: project.coverImageAddress, partnerProgramId: project.partnerProgram?.programId ?? null, }); + this.region?.updateValueAndValidity({ emitEvent: false }); if (project.partnerProgram) { this.relationId.set(project.partnerProgram?.programLinkId); @@ -80,7 +93,7 @@ export class ProjectFormService { } links.forEach(link => { - linksFormArray.push(this.fb.control(link, [Validators.required])); + linksFormArray.push(this.fb.nonNullable.control(link, [Validators.required])); }); } diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.spec.ts new file mode 100644 index 000000000..8c1e8b168 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.spec.ts @@ -0,0 +1,121 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { Subject, of, throwError } from "rxjs"; +import { Invite } from "@domain/invite/invite.model"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { UpdateInviteUseCase } from "@api/invite/use-cases/update-invite.use-case"; +import { RevokeInviteUseCase } from "@api/invite/use-cases/revoke-invite.use-case"; +import { ProjectTeamService } from "./project-team.service"; +import { ProjectTeamUIService } from "./ui/project-team-ui.service"; + +describe("ProjectTeamService invite submission", () => { + let service: ProjectTeamService; + let ui: ProjectTeamUIService; + let request: Subject; + const repo = { sendForUser: vi.fn() }; + const link = "https://app.procollab.ru/office/profile/13"; + + beforeEach(() => { + request = new Subject(); + repo.sendForUser.mockReset().mockReturnValue(request); + TestBed.configureTestingModule({ + providers: [ + ProjectTeamService, + ProjectTeamUIService, + { provide: InviteRepositoryPort, useValue: repo }, + { provide: UpdateInviteUseCase, useValue: {} }, + { provide: RevokeInviteUseCase, useValue: {} }, + ], + }); + service = TestBed.inject(ProjectTeamService); + ui = TestBed.inject(ProjectTeamUIService); + service.setupDynamicValidation(); + ui.showInviteFields.set(true); + ui.inviteForm.patchValue({ link, role: "Дизайнер" }); + }); + + it("keeps the form open while pending, blocks duplicate sends, hides/reset only on success", () => { + service.submitInvite(5); + service.submitInvite(5); + expect(repo.sendForUser).toHaveBeenCalledExactlyOnceWith(13, 5, "Дизайнер", ""); + expect(ui.showInviteFields()).toBe(true); + expect(ui.inviteFormIsSubmitting().status).toBe("loading"); + const invite = { id: 10, isAccepted: null } as Invite; + request.next(invite); + request.complete(); + expect(ui.invites()).toEqual([invite]); + expect(ui.showInviteFields()).toBe(false); + expect(ui.link?.value).toBeNull(); + expect(ui.role?.value).toBeNull(); + expect(ui.inviteSubmitError()).toBeNull(); + expect(ui.inviteSubmitInitiated()).toBe(false); + expect(ui.inviteFormIsSubmitting().status).toBe("initial"); + service.submitInvite(5); + expect(repo.sendForUser).toHaveBeenCalledTimes(1); + expect(ui.link?.hasError("required")).toBe(true); + }); + + it.each(["", "not-a-profile-link"])( + "invalid link '%s' never calls API through real ValidationService", + value => { + ui.link?.setValue(value); + service.submitInvite(5); + expect(repo.sendForUser).not.toHaveBeenCalled(); + expect(ui.link?.touched).toBe(true); + expect(ui.inviteForm.invalid).toBe(true); + expect(ui.inviteFormIsSubmitting().status).toBe("initial"); + }, + ); + + it("invalid role blocks API and a new submission clears a previous backend error", () => { + ui.role?.setValue(""); + ui.applyErrorSubmitInvite({ kind: "unknown", message: "Controlled" }); + service.submitInvite(5); + expect(repo.sendForUser).not.toHaveBeenCalled(); + expect(ui.inviteSubmitError()).toBeNull(); + expect(ui.role?.hasError("required")).toBe(true); + }); + + it("maps actual HTTP error, preserves values and permits retry", () => { + service.submitInvite(5); + request.error( + new HttpErrorResponse({ + status: 400, + error: { user: ["Пользователь уже является лидером проекта."] }, + }), + ); + expect(ui.inviteSubmitError()?.kind).toBe("already_leader"); + expect(ui.inviteFormIsSubmitting().status).toBe("failure"); + expect(ui.showInviteFields()).toBe(true); + expect(ui.inviteForm.value).toMatchObject({ link, role: "Дизайнер" }); + expect(ui.invites()).toEqual([]); + repo.sendForUser.mockReturnValue(of({ id: 11 } as Invite)); + service.submitInvite(5); + expect(repo.sendForUser).toHaveBeenCalledTimes(2); + expect(ui.showInviteFields()).toBe(false); + expect(ui.inviteSubmitError()).toBeNull(); + }); + + it.each(["link", "role", "specialization"])("editing %s clears only the backend error", field => { + repo.sendForUser.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500, error: "error" })), + ); + service.submitInvite(5); + expect(ui.inviteSubmitError()?.kind).toBe("server"); + ui.inviteForm.get(field)?.setValue(null); + expect(ui.inviteSubmitError()).toBeNull(); + ui.link?.setValue(""); + expect(ui.link?.hasError("required")).toBe(true); + ui.link?.setValue("bad"); + expect(ui.link?.hasError("pattern")).toBe(true); + }); + + it("destroy cancels pending request", () => { + service.submitInvite(5); + expect(request.observed).toBe(true); + TestBed.resetTestingModule(); + expect(request.observed).toBe(false); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts index 2c2a76c0c..6e9e53e38 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts @@ -6,7 +6,7 @@ import { ProjectTeamUIService } from "./ui/project-team-ui.service"; import { SendForUserUseCase } from "../../../invite/use-cases/send-for-user.use-case"; import { UpdateInviteUseCase } from "../../../invite/use-cases/update-invite.use-case"; import { RevokeInviteUseCase } from "../../../invite/use-cases/revoke-invite.use-case"; -import { loading } from "@domain/shared/async-state"; +import { isLoading, loading } from "@domain/shared/async-state"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; /** Сервис для управления приглашениями участников команды проекта. */ @@ -26,6 +26,8 @@ export class ProjectTeamService { private readonly inviteFormIsSubmitting = this.projectTeamUIService.inviteFormIsSubmitting; public submitInvite(projectId: number): void { + if (isLoading(this.inviteFormIsSubmitting())) return; + this.projectTeamUIService.applyClearLinkError(); this.inviteSubmitInitiated.set(true); // Проверка валидности формы if (!this.validationService.getFormValidation(this.inviteForm)) { @@ -50,7 +52,7 @@ export class ProjectTeamService { .subscribe({ next: result => { if (!result.ok) { - this.projectTeamUIService.applyErrorSubmitInvite(result.error.cause); + this.projectTeamUIService.applyErrorSubmitInvite(result.error); return; } @@ -85,15 +87,9 @@ export class ProjectTeamService { } public setupDynamicValidation(): void { - this.inviteForm - .get("link") - ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(value => { - if (value === "") { - this.inviteForm.get("link")?.clearValidators(); - this.inviteForm.get("link")?.updateValueAndValidity(); - } - this.projectTeamUIService.applyClearLinkError(); - }); + // Clear only the backend error; required/pattern validators remain independent. + this.inviteForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.projectTeamUIService.applyClearLinkError(); + }); } } diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts new file mode 100644 index 000000000..973149f2f --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts @@ -0,0 +1,465 @@ +/** @format */ + +import { signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { ActivatedRoute, provideRouter, Router } from "@angular/router"; +import { BehaviorSubject, of, Subject, throwError } from "rxjs"; +import { HttpErrorResponse } from "@angular/common/http"; +import { NavService } from "@api/shared/nav.service"; +import { SearchesService } from "@api/searches/searches.service"; +import { ProjectStepService } from "@api/project/project-step.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { programLinkFields } from "@domain/project/program-link-fields.fixture"; +import { ProgramLinkFields } from "@domain/project/program-link-fields.model"; +import { Project } from "@domain/project/project.model"; +import { ok } from "@domain/shared/result.type"; +import { AssignProjectProgramUseCase } from "@api/program/use-cases/assign-project-program"; +import { DeleteProjectUseCase } from "../../use-cases/delete-project.use-case"; +import { UpdateFormUseCase } from "../../use-cases/update-form.use-case"; +import { ProjectAdditionalService } from "./project-additional.service"; +import { ProjectFormService } from "./project-form.service"; +import { ProjectGoalService } from "./project-goals.service"; +import { ProjectPartnerService } from "./project-partner.service"; +import { ProjectResourceService } from "./project-resources.service"; +import { ProjectAchievementsService } from "./project-achievements.service"; +import { ProjectContactsService } from "./project-contacts.service"; +import { ProjectVacancyService } from "./project-vacancy.service"; +import { ProjectsEditUIInfoService } from "./ui/projects-edit-ui-info.service"; +import { ProjectVacancyUIService } from "./ui/project-vacancy-ui.service"; +import { ProjectTeamUIService } from "./ui/project-team-ui.service"; +import { ProjectsEditInfoService } from "./projects-edit-info.service"; + +describe("ProjectsEditInfoService canonical relation integration", () => { + let service: ProjectsEditInfoService; + let additional: ProjectAdditionalService; + let params: BehaviorSubject<{ projectId: string }>; + let query: BehaviorSubject>; + let data: BehaviorSubject>; + const repo = { getProgramLinkFields: vi.fn(), updateProgramLinkFields: vi.fn() }; + const programRepo = { submitCompettetiveProject: vi.fn() }; + const update = { execute: vi.fn() }; + const assign = { execute: vi.fn() }; + const step = { currentStep: signal("additional"), setStepFromRoute: vi.fn() }; + const ui = { + leaderId: signal(1), + isProjectAssignToProgram: signal(false), + fromProgram: signal(""), + fromProgramOpen: signal(false), + applySendDescision: vi.fn(), + applyCloseSendDescisionModal: vi.fn(), + applyOpenSendDescisionLateModal: vi.fn(), + applyOpenAssignProjectModal: vi.fn(), + }; + function project(id = 55): Project { + // The embedded legacy program is deliberately B. Explicit route link A is authoritative. + return { + id, + partnerProgram: { + programId: 99, + programLinkId: 900, + canSubmit: false, + programFields: [{ name: "legacy", options: ["WRONG PROGRAM"] }], + programFieldValues: [{ fieldName: "case", value: "WRONG PROGRAM" }], + }, + collaborators: [], + vacancies: [], + } as unknown as Project; + } + + beforeEach(() => { + const fb = new FormBuilder(); + const form = fb.group({ draft: [true], name: ["Project"], partnerProgramId: [99] }); + params = new BehaviorSubject({ projectId: "55" }); + query = new BehaviorSubject>({ + programLinkId: "700", + fromProgram: true, + }); + data = new BehaviorSubject>({ data: [project(), [], [], [], []] }); + repo.getProgramLinkFields.mockReset().mockReturnValue(of(programLinkFields())); + repo.updateProgramLinkFields.mockReset().mockReturnValue(of(undefined)); + programRepo.submitCompettetiveProject.mockReset().mockReturnValue(of({})); + update.execute.mockReset().mockReturnValue(of(ok(project()))); + assign.execute.mockReset().mockReturnValue(of(ok({ newProjectId: 55 }))); + ui.applySendDescision.mockClear(); + ui.applyCloseSendDescisionModal.mockClear(); + ui.applyOpenSendDescisionLateModal.mockClear(); + ui.applyOpenAssignProjectModal.mockClear(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + ProjectsEditInfoService, + ProjectAdditionalService, + { + provide: ActivatedRoute, + useValue: { + params, + queryParams: query, + data, + snapshot: { + params: { projectId: "55" }, + paramMap: { get: () => "55" }, + queryParams: query.value, + }, + }, + }, + { provide: NavService, useValue: { setNavTitle: vi.fn() } }, + { provide: SearchesService, useValue: { inlineSkills: signal([]) } }, + { provide: ProjectStepService, useValue: step }, + { provide: LoggerService, useValue: { error: vi.fn() } }, + { provide: SnackbarService, useValue: { error: vi.fn(), success: vi.fn() } }, + { provide: IndustryRepositoryPort, useValue: { industries: signal([]) } }, + { provide: SkillsRepositoryPort, useValue: { getSkillsNested: () => of([]) } }, + { + provide: ProjectFormService, + useValue: { + getForm: () => form, + getFormValue: () => form.value, + achievements: fb.array([]), + links: fb.array([]), + initializeProjectData: vi.fn(), + clearAllValidationErrors: vi.fn(), + }, + }, + { + provide: ProjectAchievementsService, + useValue: { syncAchievementsItems: vi.fn(), clearAllAchievementsErrors: vi.fn() }, + }, + { provide: ProjectContactsService, useValue: { syncLinksItems: vi.fn() } }, + { + provide: ProjectGoalService, + useValue: { goals: fb.array([]), initializeGoalsFromProject: vi.fn() }, + }, + { + provide: ProjectPartnerService, + useValue: { partners: fb.array([]), initializePartnerFromProject: vi.fn() }, + }, + { + provide: ProjectResourceService, + useValue: { resources: fb.array([]), initializeResourcesFromProject: vi.fn() }, + }, + { + provide: ProjectTeamUIService, + useValue: { applySetInvites: vi.fn(), applySetCollaborators: vi.fn() }, + }, + { + provide: ProjectVacancyUIService, + useValue: { + applySetVacancies: vi.fn(), + isDirty: () => false, + applyValidateForm: () => false, + }, + }, + { provide: ProjectVacancyService, useValue: {} }, + { provide: ProjectsEditUIInfoService, useValue: ui }, + { provide: AssignProjectProgramUseCase, useValue: assign }, + { provide: DeleteProjectUseCase, useValue: {} }, + { provide: UpdateFormUseCase, useValue: update }, + { provide: ProjectProgramRepositoryPort, useValue: repo }, + { provide: ProgramRepositoryPort, useValue: programRepo }, + ], + }); + vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); + service = TestBed.inject(ProjectsEditInfoService); + additional = TestBed.inject(ProjectAdditionalService); + }); + + it("initializes from GET A, not embedded B, and fromProgram reload never chooses a case", () => { + service.loadProgramTagsAndProject(); + expect(repo.getProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700); + expect(service.additionalForm).toBe(additional.getAdditionalForm()); + expect(service.additionalForm.get("case")?.value).toBe(""); + expect(service.additionalForm.get("legacy")).toBeNull(); + query.next({ ...query.value, editingStep: "main" }); + expect(repo.getProgramLinkFields).toHaveBeenCalledTimes(1); + }); + + it("draft without case PUTs only A and continues project save", () => { + service.loadProgramTagsAndProject(); + service.saveProjectAsDraft(); + expect(repo.updateProgramLinkFields.mock.calls[0][0]).toBe(700); + expect( + repo.updateProgramLinkFields.mock.calls[0][1].some( + (value: { fieldId: number }) => value.fieldId === 5, + ), + ).toBe(false); + expect(update.execute).toHaveBeenCalledOnce(); + expect(update.execute).toHaveBeenCalledWith({ id: 55, data: { name: "Project", draft: true } }); + expect(service.projectForm.get("partnerProgramId")?.value).toBe(99); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }); + + it("empty case blocks publish; selected case PUT/submit both use A", () => { + service.loadProgramTagsAndProject(); + service.saveProjectAsPublished(); + expect(service.additionalForm.get("case")?.touched).toBe(true); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + service.additionalForm.get("case")?.setValue("B"); + service.saveProjectAsPublished(); + expect(ui.applySendDescision).toHaveBeenCalledOnce(); + service.closeSendingDescisionModal(); + expect(repo.updateProgramLinkFields.mock.calls[0][0]).toBe(700); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); + expect(update.execute).toHaveBeenCalledOnce(); + expect(service.isCompetitive()).toBe(true); + expect(update.execute).toHaveBeenCalledWith({ + id: 55, + data: { name: "Project", draft: false }, + }); + }); + + it("mismatched GET project blocks draft PUT and main save", () => { + repo.getProgramLinkFields.mockReturnValue(of(programLinkFields({ projectId: 99 }))); + service.loadProgramTagsAndProject(); + service.saveProjectAsDraft(); + expect(additional.loadFailed()).toBe(true); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(update.execute).not.toHaveBeenCalled(); + }); + + it("submitted additional form does not block main save or trigger another fields PUT", () => { + const snapshot = programLinkFields({ submitted: true }); + snapshot.fields[0].value = "B"; + repo.getProgramLinkFields.mockReturnValue(of(snapshot)); + service.loadProgramTagsAndProject(); + service.saveProjectAsPublished(); + expect(service.additionalForm.disabled).toBe(true); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledOnce(); + }); + + it("query-only relation change cancels old GET and uses exact new link", () => { + const old = new Subject(); + repo.getProgramLinkFields + .mockReturnValueOnce(old) + .mockReturnValue(of(programLinkFields({ programLinkId: 800, fields: [] }))); + service.loadProgramTagsAndProject(); + query.next({ programLinkId: "800" }); + expect(old.observed).toBe(false); + old.next(programLinkFields()); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [800]]); + expect(service.activeProgramLinkId()).toBe(800); + expect(additional.partnerProgramFields()).toEqual([]); + }); + + it("route project change waits for matching resolver data and cancels old GET immediately", () => { + const old = new Subject(); + repo.getProgramLinkFields + .mockReturnValueOnce(old) + .mockReturnValue(of(programLinkFields({ projectId: 56 }))); + service.loadProgramTagsAndProject(); + params.next({ projectId: "56" }); + expect(old.observed).toBe(false); + expect(service.activeProgramLinkId()).toBeNull(); + expect(repo.getProgramLinkFields).toHaveBeenCalledTimes(1); + data.next({ data: [project(56), [], [], [], []] }); + expect(repo.getProgramLinkFields).toHaveBeenCalledTimes(2); + expect(service.activeProgramLinkId()).toBe(700); + }); + + function legacyBAllowsSubmission(): void { + const legacy = project(); + legacy.partnerProgram.canSubmit = true; + data.next({ data: [legacy, [], [], [], []] }); + } + + it("closed canonical A does not publish or send any fields even if legacy B allows submission", () => { + legacyBAllowsSubmission(); + repo.getProgramLinkFields.mockReturnValue( + of(programLinkFields({ submissionOpen: false, canSubmit: false })), + ); + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")?.setValue("B"); + service.saveProjectAsPublished(); + expect(service.isCompetitive()).toBe(true); + expect(ui.applyOpenSendDescisionLateModal).toHaveBeenCalledOnce(); + expect(ui.applySendDescision).not.toHaveBeenCalled(); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + expect(update.execute).not.toHaveBeenCalled(); + expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); + expect(service.projectForm.get("draft")?.value).toBe(true); + }); + + it("noncompetitive canonical A saves normally without submit despite legacy B", () => { + legacyBAllowsSubmission(); + repo.getProgramLinkFields.mockReturnValue( + of(programLinkFields({ isCompetitive: false, canSubmit: false })), + ); + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")?.setValue("B"); + service.saveProjectAsPublished(); + expect(service.isCompetitive()).toBe(false); + expect(ui.applySendDescision).not.toHaveBeenCalled(); + expect(repo.updateProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700, [ + { fieldId: 5, valueText: "B" }, + { fieldId: 6, valueText: "Y" }, + { fieldId: 7, valueText: "" }, + { fieldId: 8, valueText: "false" }, + ]); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { name: "Project", draft: false }, + }); + }); + + it("single-link happy path: validation, confirmation, PUT, submit, main save and success exactly once", () => { + const single = project(); + single.partnerProgram.programId = 12; + single.partnerProgram.programLinkId = 700; + data.next({ data: [single, [], [], [], []] }); + query.next({}); // Exercise the single-link backward-compatible ID fallback. + service.projectForm.get("partnerProgramId")?.setValue(12); + const put = new Subject(); + const submission = new Subject(); + repo.updateProgramLinkFields.mockReturnValue(put); + programRepo.submitCompettetiveProject.mockReturnValue(submission); + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")?.setValue("B"); + service.saveProjectAsPublished(); + expect(ui.applySendDescision).toHaveBeenCalledOnce(); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + service.closeSendingDescisionModal(); + expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1); + expect(repo.updateProgramLinkFields.mock.calls[0][0]).toBe(700); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + expect(update.execute).not.toHaveBeenCalled(); + put.next(); + put.complete(); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); + expect(update.execute).not.toHaveBeenCalled(); + submission.next({}); + submission.complete(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { name: "Project", draft: false }, + }); + expect(service.projectForm.get("partnerProgramId")?.value).toBe(12); + expect(additional.submitted()).toBe(true); + expect(additional.canSubmit()).toBe(false); + expect(TestBed.inject(SnackbarService).success).toHaveBeenCalledExactlyOnceWith( + "данные успешно сохранены", + ); + expect(TestBed.inject(Router).navigateByUrl).toHaveBeenCalledOnce(); + }); + + it("excludes binding from a new ordinary payload without mutating the raw value or control", () => { + service.loadProgramTagsAndProject(); + const raw = Object.freeze({ name: "Project", draft: true, partnerProgramId: 99 }); + vi.spyOn(TestBed.inject(ProjectFormService), "getFormValue").mockReturnValue(raw); + service.saveProjectAsDraft(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { name: "Project", draft: true }, + }); + expect(raw.partnerProgramId).toBe(99); + expect(service.projectForm.get("partnerProgramId")?.value).toBe(99); + }); + + it("preserves ordinary payload semantics without canonical context", () => { + data.next({ data: [{ ...project(), partnerProgram: null }, [], [], [], []] }); + query.next({}); + service.loadProgramTagsAndProject(); + service.saveProjectAsDraft(); + expect(additional.activeProgramLinkId()).toBeNull(); + expect(repo.getProgramLinkFields).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { name: "Project", draft: true, partnerProgramId: 99 }, + }); + }); + + it("preserves the explicit dedicated assignment action and its selected program ID", () => { + service.loadProgramTagsAndProject(); + vi.spyOn(TestBed.inject(Router), "navigate").mockResolvedValue(true); + service.assignProjectToProgram(); + expect(assign.execute).toHaveBeenCalledExactlyOnceWith(55, 99); + expect(ui.applyOpenAssignProjectModal).toHaveBeenCalledOnce(); + expect(update.execute).not.toHaveBeenCalled(); + }); + + it("backend deadline race keeps late modal, refreshes metadata and blocks another submission", () => { + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")?.setValue("B"); + service.additionalForm.get("note")?.setValue("local note"); + const reload = new Subject(); + repo.getProgramLinkFields.mockReturnValue(reload); + programRepo.submitCompettetiveProject.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { detail: "Срок подачи проектов в программу завершён." }, + }), + ), + ); + service.saveProjectAsPublished(); + service.closeSendingDescisionModal(); + expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce(); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); + expect(ui.applyOpenSendDescisionLateModal).toHaveBeenCalledOnce(); + expect(update.execute).not.toHaveBeenCalled(); + expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); + expect(service.projFormIsSubmitting$().status).toBe("failure"); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(additional.activeProgramLinkId()).toBeNull(); + service.saveProjectAsPublished(); // A pending canonical refresh cannot submit either. + reload.next(programLinkFields({ submissionOpen: false, canSubmit: false })); + reload.complete(); + expect(additional.submissionOpen()).toBe(false); + expect(additional.canSubmit()).toBe(false); + expect(service.additionalForm.get("case")?.value).toBe("B"); + expect(service.additionalForm.get("note")?.value).toBe("local note"); + service.saveProjectAsPublished(); + expect(ui.applyOpenSendDescisionLateModal).toHaveBeenCalledTimes(2); + expect(ui.applySendDescision).toHaveBeenCalledOnce(); + expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce(); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); + expect(update.execute).not.toHaveBeenCalled(); + expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); + }); + + it.each([ + ["submit", "Проект уже был сдан на проверку."], + ["put", "Нельзя изменять значения полей программы после сдачи проекта на проверку."], + ])( + "already-submitted %s race refreshes read-only state without main save or success", + (source, detail) => { + service.loadProgramTagsAndProject(); + service.additionalForm.get("case")?.setValue("B"); + const frozen = programLinkFields({ submitted: true, canSubmit: false }); + frozen.fields[0].value = "B"; + repo.getProgramLinkFields.mockReturnValue(of(frozen)); + (source === "put" + ? repo.updateProgramLinkFields + : programRepo.submitCompettetiveProject + ).mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { detail }, + }), + ), + ); + service.saveProjectAsPublished(); + service.closeSendingDescisionModal(); + expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); + expect(service.additionalForm.disabled).toBe(true); + expect(additional.submitted()).toBe(true); + expect(update.execute).not.toHaveBeenCalled(); + expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); + expect(service.projFormIsSubmitting$().status).toBe("failure"); + expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce(); + expect(programRepo.submitCompettetiveProject).toHaveBeenCalledTimes(source === "put" ? 0 : 1); + }, + ); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts index b627882f1..5a91d080a 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts @@ -2,7 +2,18 @@ import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; import { NavService } from "@api/shared/nav.service"; -import { EMPTY, distinctUntilChanged, forkJoin, map, Observable, of, switchMap, tap } from "rxjs"; +import { + EMPTY, + combineLatest, + distinctUntilChanged, + finalize, + forkJoin, + map, + Observable, + of, + switchMap, + tap, +} from "rxjs"; import { Project } from "@domain/project/project.model"; import { ActivatedRoute, Router } from "@angular/router"; import { Goal } from "@domain/project/goals.model"; @@ -84,10 +95,11 @@ export class ProjectsEditInfoService { readonly achievements = this.projectFormService.achievements; // Id связи проекта и программы - readonly relationId = computed(() => this.projectFormService.relationId); + readonly activeProgramLinkId = this.projectAdditionalService.activeProgramLinkId; private readonly leaderId = this.projectsEditUIInfoService.leaderId; - private readonly isCompetitive = this.projectsEditUIInfoService.isCompetitive; + readonly isCompetitive = this.projectAdditionalService.isCompetitive; + readonly submitted = this.projectAdditionalService.submitted; private readonly isProjectAssignToProgram = this.projectsEditUIInfoService.isProjectAssignToProgram; @@ -219,12 +231,26 @@ export class ProjectsEditInfoService { } saveProjectAsPublished(): void { + if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; + if (!this.submissionAvailable()) return; this.projectForm.get("draft")?.patchValue(false); this.submitMode.set("published"); - if (!this.isCompetitive()) { + this.additionalForm.markAllAsTouched(); + if ( + !this.projectAdditionalService.submitted() && + !this.validationService.getFormValidation(this.additionalForm) + ) { + this.projSubmitInitiated.set(true); + this.projectStepService.setStepFromRoute("additional"); + return; + } + + if (!this.isCompetitive() || this.projectAdditionalService.submitted()) { this.projFormIsSubmitting$.set(loading()); - this.submitProjectForm(); + if (this.activeProgramLinkId() && !this.projectAdditionalService.submitted()) { + this.sendAdditionalFields(this.activeProgramLinkId()!, false); + } else this.submitProjectForm(); return; } @@ -236,6 +262,7 @@ export class ProjectsEditInfoService { if (!projectValid || !additionalValid) { this.projSubmitInitiated.set(true); + if (!additionalValid) this.projectStepService.setStepFromRoute("additional"); return; } @@ -248,30 +275,32 @@ export class ProjectsEditInfoService { } saveProjectAsDraft(): void { + if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; this.clearAllValidationErrors(); this.projectForm.get("draft")?.patchValue(true); this.submitMode.set("draft"); - const partnerProgramId = this.projectForm.get("partnerProgramId")?.value; - this.projectForm.patchValue({ partnerProgramId }); this.projFormIsSubmitting$.set(loading()); - if (this.isCompetitive()) { - const projectId = Number(this.route.snapshot.params["projectId"]); - const relationId = this.relationId(); - this.sendAdditionalFields(projectId, relationId()); + if (this.activeProgramLinkId() && !this.projectAdditionalService.submitted()) { + this.sendAdditionalFields(this.activeProgramLinkId()!, false); } else { this.submitProjectForm(); } } submitProjectForm(): void { + if (!this.additionalContextReady()) return; const isDraft = this.projectForm.get("draft")?.value === true; this.projectFormService.achievements.controls.forEach(achievementForm => { achievementForm.markAllAsTouched(); }); - const payload = this.projectFormService.getFormValue(); + const rawPayload = this.projectFormService.getFormValue(); + const payload = { ...rawPayload }; + // In a verified canonical edit this is NOT a legacy bind/unbind command. + // Preserve the control and dedicated assignment flow; do not mutate raw form data. + if (this.activeProgramLinkId()) delete payload.partnerProgramId; const projectId = Number(this.route.snapshot.paramMap.get("projectId")); if (this.projectVacancyUIService.isDirty()) { @@ -288,7 +317,8 @@ export class ProjectsEditInfoService { } else { if ( !this.validationService.getFormValidation(this.projectForm) || - !this.validationService.getFormValidation(this.additionalForm) || + (!this.projectAdditionalService.submitted() && + !this.validationService.getFormValidation(this.additionalForm)) || this.projectVacancyUIService.applyValidateForm() ) { return; @@ -327,17 +357,20 @@ export class ProjectsEditInfoService { closeSendingDescisionModal(): void { this.projectsEditUIInfoService.applyCloseSendDescisionModal(); - - const projectId = Number(this.route.snapshot.params["projectId"]); - const relationId = this.relationId(); - + if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; + if (!this.submissionAvailable()) return; + if (this.validateAdditionalFields()) { + this.projSubmitInitiated.set(true); + return; + } this.projFormIsSubmitting$.set(loading()); - this.sendAdditionalFields(projectId, relationId()); + if (this.projectAdditionalService.submitted()) this.submitProjectForm(); + else if (this.activeProgramLinkId()) + this.sendAdditionalFields(this.activeProgramLinkId()!, this.isCompetitive()); } loadProgramTagsAndProject(): void { // Сброс состояния перед загрузкой - this.isCompetitive.set(false); this.isProjectAssignToProgram.set(false); this.route.data @@ -367,23 +400,26 @@ export class ProjectsEditInfoService { // Синхронизируем ссылки после инициализации данных проекта this.projectContactsService.syncLinksItems(this.projectFormService.links); - if (project.partnerProgram) { - this.isCompetitive.set( - !!project.partnerProgram.programId && project.partnerProgram.canSubmit, - ); - this.isProjectAssignToProgram.set(!!project.partnerProgram.programId); - - const isFirstVisitFromProgram = !!this.route.snapshot.queryParams["fromProgram"]; - this.projectAdditionalService.initializeAdditionalForm( - project.partnerProgram?.programFields, - project.partnerProgram?.programFieldValues, - isFirstVisitFromProgram, - ); - } - this.projectVacancyUIService.applySetVacancies(project.vacancies); }, ); + + // Params change before the new resolver data arrives. Clear the old relation immediately, + // and wait for a matching resolved Project before requesting canonical definitions. + combineLatest([this.route.params, this.route.queryParams, this.route.data]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([params, query, data]) => { + const id = Number(params["projectId"]); + const project: Project | undefined = data["data"]?.[0]; + const matches = Number.isSafeInteger(id) && id > 0 && project?.id === id; + this.profileId.set(id); + this.projectAdditionalService.setContext( + matches ? id : null, + query["programLinkId"], + project?.partnerProgram?.programLinkId, + ); + this.isProjectAssignToProgram.set(this.projectAdditionalService.hasProgramLink()); + }); } onSearchSkill(query: string): void { @@ -441,8 +477,6 @@ export class ProjectsEditInfoService { return true; } - // Подготавливаем поля для отправки (убираем валидаторы с заполненных полей) - this.projectAdditionalService.prepareFieldsForSubmit(); return false; } @@ -525,49 +559,55 @@ export class ProjectsEditInfoService { } } - private sendAdditionalFields(projectId: number, relationId: number): void { - const isDraft = this.projectForm.get("draft")?.value === true; + private additionalContextReady(): boolean { + if (!this.projectAdditionalService.hasProgramLink() || this.activeProgramLinkId()) return true; + this.projectStepService.setStepFromRoute("additional"); + this.projFormIsSubmitting$.set(failure("additional_fields_unavailable")); + return false; + } - this.projectAdditionalService - .sendAdditionalFieldsValues(projectId) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: result => { - if (!result.ok) { - this.logger.error("Error sending additional fields:", result.error.cause); - this.projectAdditionalService.resetSendingState(); - this.submitMode.set("draft"); - return; - } + /** A closed competitive link must never silently fall through to normal publishing. */ + private submissionAvailable(): boolean { + if ( + !this.isCompetitive() || + this.projectAdditionalService.submitted() || + this.projectAdditionalService.canSubmit() + ) { + return true; + } + this.projFormIsSubmitting$.set(failure("submission_unavailable")); + if (!this.projectAdditionalService.submissionOpen()) { + this.projectsEditUIInfoService.applyOpenSendDescisionLateModal(); + } else { + this.snackBarService.error( + "Сейчас проект нельзя сдать. Обновите дополнительные сведения программы.", + ); + } + return false; + } - if (!isDraft) { - this.projectAdditionalService - .submitCompettetiveProject(relationId) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(submitResult => { - if (!submitResult.ok) { - this.logger.error( - "Error submitting competitive project:", - submitResult.error.cause, - ); - this.projectAdditionalService.resetSendingState(); - this.submitMode.set("draft"); - return; - } - - this.projectAdditionalService.resetSendingState(); - this.submitProjectForm(); - }); - } else { - this.projectAdditionalService.resetSendingState(); - this.submitProjectForm(); + private sendAdditionalFields(programLinkId: number, submit: boolean): void { + let fieldsSaved = false; + this.projectAdditionalService + .save(programLinkId, submit) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => { + if (!fieldsSaved && isLoading(this.projFormIsSubmitting$())) + this.projFormIsSubmitting$.set(initial()); + }), + ) + .subscribe(result => { + if (!result.ok) { + this.projFormIsSubmitting$.set(failure(result.error.kind)); + if (result.error.kind === "submission_closed") { + this.projectsEditUIInfoService.applyOpenSendDescisionLateModal(); } - }, - error: error => { - this.logger.error("Error sending additional fields:", error); - this.projectAdditionalService.resetSendingState(); - this.submitMode.set("draft"); - }, + this.projectStepService.setStepFromRoute("additional"); + return; + } + fieldsSaved = true; + this.submitProjectForm(); }); } } diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.spec.ts new file mode 100644 index 000000000..b5f37dbe8 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.spec.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectTeamUIService } from "./project-team-ui.service"; + +describe("ProjectTeamUIService", () => { + it("reset preserves frontend validation and clears only invite transient state", () => { + TestBed.configureTestingModule({ providers: [ProjectTeamUIService] }); + const ui = TestBed.inject(ProjectTeamUIService); + ui.inviteForm.patchValue({ link: "bad", role: "Дизайнер" }); + ui.inviteSubmitInitiated.set(true); + ui.applyErrorSubmitInvite({ kind: "network", message: "Controlled network message" }); + ui.resetInviteForm(); + expect(ui.inviteForm.invalid).toBe(true); + expect(ui.link?.hasError("required")).toBe(true); + expect(ui.role?.hasError("required")).toBe(true); + expect(ui.inviteSubmitError()).toBeNull(); + expect(ui.inviteSubmitInitiated()).toBe(false); + expect(ui.inviteFormIsSubmitting().status).toBe("initial"); + ui.link?.setValue("invalid"); + expect(ui.link?.hasError("pattern")).toBe(true); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts index f4699302c..ac5845942 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts @@ -3,6 +3,7 @@ import { computed, inject, Injectable, signal } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Invite } from "@domain/invite/invite.model"; +import { InviteSendError } from "@domain/invite/invite-send-error"; import { Collaborator } from "@domain/project/collaborator.model"; import { AsyncState, failure, initial } from "@domain/shared/async-state"; @@ -14,7 +15,8 @@ export class ProjectTeamUIService { readonly invites = signal([]); readonly collaborators = signal([]); readonly isInviteModalOpen = signal(false); - readonly inviteNotExistingError = signal(null); + readonly inviteSubmitError = signal(null); + readonly showInviteFields = signal(false); // Состояние отправки формы readonly inviteSubmitInitiated = signal(false); @@ -50,9 +52,7 @@ export class ProjectTeamUIService { } applyClearLinkError(): void { - if (this.inviteNotExistingError()) { - this.inviteNotExistingError.set(null); - } + this.inviteSubmitError.set(null); } applySetInvites(invites: Invite[]): void { @@ -78,12 +78,13 @@ export class ProjectTeamUIService { applySubmitInvite(invite: Invite): void { this.invites.update(list => [...list, invite]); this.resetInviteForm(); + this.showInviteFields.set(false); this.applyCloseInviteModal(); } - applyErrorSubmitInvite(err: any): void { - this.inviteNotExistingError.set(err); - this.inviteFormIsSubmitting.set(failure("invite_error")); + applyErrorSubmitInvite(error: InviteSendError): void { + this.inviteSubmitError.set(error); + this.inviteFormIsSubmitting.set(failure(error.kind)); } applyEditInvitation(params: { inviteId: number; role: string; specialization: string }): void { @@ -111,13 +112,8 @@ export class ProjectTeamUIService { resetInviteForm(): void { this.inviteForm.reset(); - Object.keys(this.inviteForm.controls).forEach(name => { - const ctrl = this.inviteForm.get(name); - ctrl?.clearValidators(); - ctrl?.markAsPristine(); - ctrl?.updateValueAndValidity(); - }); - this.inviteNotExistingError.set(null); + this.inviteSubmitInitiated.set(false); + this.inviteSubmitError.set(null); this.inviteFormIsSubmitting.set(initial()); } } diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts index 31406c946..27212a592 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts @@ -15,8 +15,6 @@ export class ProjectsEditUIInfoService { readonly fromProgramOpen = signal(false); readonly projectId = this.projectsDetailUIInfoService.projectId; - // Маркер привязки проекта к конкурсной программе. - readonly isCompetitive = signal(false); readonly isProjectAssignToProgram = signal(false); // Состояния модалок и ограничений редактирования. diff --git a/projects/social_platform/src/app/api/project/program-link-fields-error.ts b/projects/social_platform/src/app/api/project/program-link-fields-error.ts new file mode 100644 index 000000000..9ec6aa9ec --- /dev/null +++ b/projects/social_platform/src/app/api/project/program-link-fields-error.ts @@ -0,0 +1,54 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramLinkFieldsError } from "@domain/project/program-link-fields.model"; + +export function mapProgramLinkFieldsError(cause: unknown): ProgramLinkFieldsError { + if (cause instanceof HttpErrorResponse) { + if (cause.status === 0) return { kind: "network", cause }; + if (cause.status >= 500) return { kind: "server", cause }; + if (cause.status === 400) { + if (cause.error?.detail === "Срок подачи проектов в программу завершён.") { + return { kind: "submission_closed", cause }; + } + if ( + cause.error?.detail === "Проект уже был сдан на проверку." || + cause.error?.detail === + "Нельзя изменять значения полей программы после сдачи проекта на проверку." + ) { + return { kind: "already_submitted", cause }; + } + if (cause.error?.detail === "Программа не является конкурсной.") { + return { kind: "not_competitive", cause }; + } + if (cause.error?.detail === "Выберите кейс перед сдачей проекта.") { + return { kind: "case_required", cause }; + } + if (cause.error?.detail === "Выбранный кейс больше недоступен. Выберите актуальный кейс.") { + return { kind: "case_unavailable", cause }; + } + } + } + return { kind: "unknown", cause }; +} + +export function programLinkFieldsErrorMessage(error: ProgramLinkFieldsError): string { + switch (error.kind) { + case "submission_closed": + return "Срок подачи проектов в программу завершён."; + case "already_submitted": + return "Проект уже был сдан на проверку."; + case "not_competitive": + return "Программа не является конкурсной. Сдача на проверку не требуется."; + case "case_required": + return "Выберите кейс перед сдачей проекта."; + case "case_unavailable": + return "Выбранный кейс больше недоступен. Выберите актуальный кейс."; + case "network": + return "Не удалось сохранить данные. Проверьте подключение и попробуйте ещё раз."; + case "server": + return "Не удалось сохранить дополнительные сведения. Попробуйте ещё раз позже."; + default: + return "Не удалось сохранить дополнительные сведения. Попробуйте ещё раз."; + } +} diff --git a/projects/social_platform/src/app/api/project/use-cases/get-program-link-fields.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/get-program-link-fields.use-case.ts new file mode 100644 index 000000000..717adb253 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-cases/get-program-link-fields.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { + ProgramLinkFields, + ProgramLinkFieldsError, +} from "@domain/project/program-link-fields.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { mapProgramLinkFieldsError } from "../program-link-fields-error"; + +@Injectable({ providedIn: "root" }) +export class GetProgramLinkFieldsUseCase { + private readonly repository = inject(ProjectProgramRepositoryPort); + + execute(programLinkId: number): Observable> { + return this.repository.getProgramLinkFields(programLinkId).pipe( + map(snapshot => ok(snapshot)), + catchError(error => of(fail(mapProgramLinkFieldsError(error)))), + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-cases/program-link-fields.use-cases.spec.ts b/projects/social_platform/src/app/api/project/use-cases/program-link-fields.use-cases.spec.ts new file mode 100644 index 000000000..3571fbd0d --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-cases/program-link-fields.use-cases.spec.ts @@ -0,0 +1,69 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { programLinkFields } from "@domain/project/program-link-fields.fixture"; +import { GetProgramLinkFieldsUseCase } from "./get-program-link-fields.use-case"; +import { UpdateProgramLinkFieldsUseCase } from "./update-program-link-fields.use-case"; + +describe("program-link fields use cases", () => { + const repository = { getProgramLinkFields: vi.fn(), updateProgramLinkFields: vi.fn() }; + beforeEach(() => { + repository.getProgramLinkFields.mockReset().mockReturnValue(of(programLinkFields())); + repository.updateProgramLinkFields.mockReset().mockReturnValue(of(undefined)); + TestBed.configureTestingModule({ + providers: [{ provide: ProjectProgramRepositoryPort, useValue: repository }], + }); + }); + + it("GET delegates the relation ID and returns a snapshot Result", async () => { + expect(await firstValueFrom(TestBed.inject(GetProgramLinkFieldsUseCase).execute(700))).toEqual({ + ok: true, + value: programLinkFields(), + }); + expect(repository.getProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700); + }); + + it("PUT delegates the relation ID/values and returns void, not a Project", async () => { + const values = [{ fieldId: 5, valueText: "B" }]; + expect( + await firstValueFrom(TestBed.inject(UpdateProgramLinkFieldsUseCase).execute(700, values)), + ).toEqual({ ok: true, value: undefined }); + expect(repository.updateProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700, values); + }); + + it.each([ + [400, "Выберите кейс перед сдачей проекта.", "case_required"], + [400, "Выбранный кейс больше недоступен. Выберите актуальный кейс.", "case_unavailable"], + [400, "Срок подачи проектов в программу завершён.", "submission_closed"], + [400, "Проект уже был сдан на проверку.", "already_submitted"], + [ + 400, + "Нельзя изменять значения полей программы после сдачи проекта на проверку.", + "already_submitted", + ], + [400, "Программа не является конкурсной.", "not_competitive"], + [500, "Срок подачи проектов в программу завершён.", "server"], + [403, "Проект уже был сдан на проверку.", "unknown"], + [400, "raw unknown error", "unknown"], + [401, "raw", "unknown"], + [403, "raw", "unknown"], + [0, "raw", "network"], + [500, "Выберите кейс перед сдачей проекта.", "server"], + ])( + "maps status %s without relying on arbitrary backend messages", + async (status, detail, kind) => { + const error = new HttpErrorResponse({ status, error: { detail } }); + repository.updateProgramLinkFields.mockReturnValue(throwError(() => error)); + repository.getProgramLinkFields.mockReturnValue(throwError(() => error)); + expect( + await firstValueFrom(TestBed.inject(UpdateProgramLinkFieldsUseCase).execute(700, [])), + ).toEqual({ ok: false, error: { kind, cause: error } }); + expect( + await firstValueFrom(TestBed.inject(GetProgramLinkFieldsUseCase).execute(700)), + ).toEqual({ ok: false, error: { kind, cause: error } }); + }, + ); +}); diff --git a/projects/social_platform/src/app/api/project/use-cases/send-project-additional-fields.use-case.spec.ts b/projects/social_platform/src/app/api/project/use-cases/send-project-additional-fields.use-case.spec.ts deleted file mode 100644 index 4ed76d608..000000000 --- a/projects/social_platform/src/app/api/project/use-cases/send-project-additional-fields.use-case.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { of, throwError } from "rxjs"; -import { SendProjectAdditionalFieldsUseCase } from "./send-project-additional-fields.use-case"; -import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; -import { Project } from "@domain/project/project.model"; -import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; - -describe("SendProjectAdditionalFieldsUseCase", () => { - let useCase: SendProjectAdditionalFieldsUseCase; - let repo: any; - - function setup(): void { - repo = { sendNewProjectFieldsValues: vi.fn() }; - TestBed.configureTestingModule({ - providers: [ - SendProjectAdditionalFieldsUseCase, - { provide: ProjectProgramRepositoryPort, useValue: repo }, - ], - }); - useCase = TestBed.inject(SendProjectAdditionalFieldsUseCase); - } - - const values: ProjectNewAdditionalProgramFields[] = []; - - it("делегирует (projectId, newValues) в sendNewProjectFieldsValues", () => { - setup(); - repo.sendNewProjectFieldsValues.mockReturnValue(of({} as Project)); - - useCase.execute(1, values).subscribe(); - - expect(repo.sendNewProjectFieldsValues).toHaveBeenCalledExactlyOnceWith(1, values); - }); - - it("при успехе возвращает ok с проектом", () => - new Promise(done => { - setup(); - const project = {} as Project; - repo.sendNewProjectFieldsValues.mockReturnValue(of(project)); - - useCase.execute(1, values).subscribe(result => { - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe(project); - done(); - }); - })); - - it("при ошибке возвращает fail { kind: 'send_project_additional_fields_error' } с cause", () => - new Promise(done => { - setup(); - const err = new Error("boom"); - repo.sendNewProjectFieldsValues.mockReturnValue(throwError(() => err)); - - useCase.execute(1, values).subscribe(result => { - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.kind).toBe("send_project_additional_fields_error"); - expect(result.error.cause).toBe(err); - } - done(); - }); - })); -}); diff --git a/projects/social_platform/src/app/api/project/use-cases/send-project-additional-fields.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/send-project-additional-fields.use-case.ts deleted file mode 100644 index 0a854a1dd..000000000 --- a/projects/social_platform/src/app/api/project/use-cases/send-project-additional-fields.use-case.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { catchError, map, Observable, of } from "rxjs"; -import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; -import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; -import { Project } from "@domain/project/project.model"; -import { fail, ok, Result } from "@domain/shared/result.type"; - -/** Сценарий: отправить доп. поля проекта для программы; ошибка → `send_project_additional_fields_error`. */ -@Injectable({ providedIn: "root" }) -export class SendProjectAdditionalFieldsUseCase { - private readonly projectProgramRepositoryPort = inject(ProjectProgramRepositoryPort); - - execute( - projectId: number, - newValues: ProjectNewAdditionalProgramFields[], - ): Observable< - Result - > { - return this.projectProgramRepositoryPort.sendNewProjectFieldsValues(projectId, newValues).pipe( - map(project => ok(project)), - catchError(error => - of(fail({ kind: "send_project_additional_fields_error" as const, cause: error })), - ), - ); - } -} diff --git a/projects/social_platform/src/app/api/project/use-cases/update-program-link-fields.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/update-program-link-fields.use-case.ts new file mode 100644 index 000000000..9f3b29aeb --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-cases/update-program-link-fields.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; +import { ProgramLinkFieldsError } from "@domain/project/program-link-fields.model"; +import { mapProgramLinkFieldsError } from "../program-link-fields-error"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +/** Partial update of additional values for exactly one program-project link. */ +@Injectable({ providedIn: "root" }) +export class UpdateProgramLinkFieldsUseCase { + private readonly projectProgramRepositoryPort = inject(ProjectProgramRepositoryPort); + + execute( + programLinkId: number, + newValues: ProjectNewAdditionalProgramFields[], + ): Observable> { + return this.projectProgramRepositoryPort.updateProgramLinkFields(programLinkId, newValues).pipe( + map(() => ok(undefined)), + catchError(error => of(fail(mapProgramLinkFieldsError(error)))), + ); + } +} diff --git a/projects/social_platform/src/app/domain/invite/invite-send-error.ts b/projects/social_platform/src/app/domain/invite/invite-send-error.ts new file mode 100644 index 000000000..4cb7a6132 --- /dev/null +++ b/projects/social_platform/src/app/domain/invite/invite-send-error.ts @@ -0,0 +1,18 @@ +/** @format */ + +/** Controlled send errors; transport details are never rendered in the invite form. */ +export interface InviteSendError { + kind: + | "user_not_found" + | "already_leader" + | "already_member" + | "already_invited" + | "not_program_participant" + | "unauthorized" + | "forbidden" + | "validation" + | "network" + | "server" + | "unknown"; + message: string; +} diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts index c5f1ee0ba..0cd3f4c67 100644 --- a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -11,6 +11,19 @@ import { PartnerProgramFields } from "../partner-program-fields.model"; import { ProjectAdditionalFields } from "../../project/project-additional-fields.model"; import { ApplyToProgramDTO } from "../dto/apply-to-program.model"; import { ApplyToProgramResponse } from "../results/apply-to-program"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, + ProgramAnalyticsAttentionQuery, +} from "../program-analytics-attention.model"; +import { + ProgramAnalyticsOverview, + ProgramAnalyticsAssignment, + ProgramAnalyticsAssignmentScope, + ProgramAnalyticsAssignmentScoreDetail, +} from "../program-analytics.model"; /** Порт репозитория программ: список/детали/создание/регистрация, проекты/участники/фильтры. */ export abstract class ProgramRepositoryPort { @@ -22,6 +35,38 @@ export abstract class ProgramRepositoryPort { abstract getOne(programId: number): Observable; + abstract getManagerOverview(programId: number): Observable; + + /** Уникальные участники без команды текущей программы, серверный поиск/пагинация. */ + abstract getManagerParticipantsWithoutTeam( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable>; + + /** Только сданные, ещё не оценённые работы; одна работа — одна строка. */ + abstract getManagerProjectsAwaitingEvaluation( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable; + + /** Несданные связи конкурсной программы с серверным поиском, страницей и сроком. */ + abstract getManagerProjectsNotSubmitted( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable; + + /** Manager-only назначения; pending включает несданные проекты. */ + abstract getManagerAssignments( + programId: number, + scope: ProgramAnalyticsAssignmentScope, + ): Observable; + + /** Оценки конкретного назначения строго внутри выбранной программы. */ + abstract getManagerAssignmentScores( + programId: number, + assignmentId: number, + ): Observable; + abstract acknowledgeWelcome(programId: number): Observable<{ welcomeAcknowledgedAt: string }>; abstract create(program: ProgramCreate): Observable; diff --git a/projects/social_platform/src/app/domain/program/program-analytics-assignment.fixture.ts b/projects/social_platform/src/app/domain/program/program-analytics-assignment.fixture.ts new file mode 100644 index 000000000..c29f89e0a --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-analytics-assignment.fixture.ts @@ -0,0 +1,85 @@ +/** @format */ +import { + ProgramAnalyticsAssignment, + ProgramAnalyticsAssignmentCriterion, + ProgramAnalyticsAssignmentScoreDetail, + ProgramAnalyticsDelayedExpert, +} from "@domain/program/program-analytics.model"; + +export function assignment( + overrides: Partial = {}, +): ProgramAnalyticsAssignment { + return { + assignmentId: 17, + expert: { + expertId: 4, + userId: 123, + firstName: "Иван", + lastName: "Иванов", + fullName: "Иван Иванов", + avatar: null, + }, + project: { id: 55, name: "Проект А" }, + status: "completed", + criteriaTotal: 5, + criteriaScored: 5, + assignedAt: "2026-09-01T10:00:00Z", + projectSubmitted: true, + projectSubmittedAt: "2026-09-01T12:00:00Z", + waitingSince: null, + waitingSeconds: null, + ...overrides, + }; +} + +export function criterion( + overrides: Partial = {}, +): ProgramAnalyticsAssignmentCriterion { + return { + criterionId: 1, + name: "Новизна", + description: "Оцените новизну решения", + type: "int", + minValue: 0, + maxValue: 10, + value: "8", + isScored: true, + ...overrides, + }; +} + +export function scoreDetail(): ProgramAnalyticsAssignmentScoreDetail { + return { + ...assignment(), + scores: [ + criterion(), + criterion({ + criterionId: 2, + name: "Комментарий", + type: "str", + value: "Хорошая проработка", + minValue: null, + maxValue: null, + }), + criterion({ criterionId: 3, name: "Актуально", type: "bool", value: "True" }), + criterion({ criterionId: 4, name: "Реализуемость", value: null, isScored: false }), + ], + }; +} + +export function delayedExpert( + overrides: Partial = {}, +): ProgramAnalyticsDelayedExpert { + return { + ...assignment().expert, + assignmentsTotal: 8, + completed: 3, + pending: 5, + overdue24H: 3, + overdue48H: 1, + oldestWaitingSince: "2026-09-01T12:00:00Z", + oldestWaitingSeconds: 187200, + severity: "critical", + ...overrides, + }; +} diff --git a/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts b/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts new file mode 100644 index 000000000..c12960a86 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.fixture.ts @@ -0,0 +1,79 @@ +/** @format */ +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProject, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProject, + ProgramAnalyticsNotSubmittedProjectsPage, +} from "./program-analytics-attention.model"; + +export const attentionParticipant = ( + overrides: Partial = {}, +): ProgramAnalyticsAttentionParticipant => ({ + userId: 123, + fullName: "Анна Петрова", + avatar: null, + city: "Набережные Челны", + registeredAt: "2026-09-01T10:00:00+03:00", + ...overrides, +}); + +export const attentionProject = ( + overrides: Partial = {}, +): ProgramAnalyticsAttentionProject => ({ + programProjectId: 70, + project: { id: 55, name: "Проект А" }, + leader: { userId: 123, fullName: "Анна Петрова", avatar: null }, + submittedAt: "2026-09-03T12:00:00+03:00", + status: "partially_evaluated", + reason: "partially_evaluated", + reasonLabel: "Частично оценено", + assignmentsTotal: 3, + assignmentsCompleted: 1, + ...overrides, +}); + +export const participantsPage = ( + overrides: Partial> = {}, +): ProgramAnalyticsAttentionPage => ({ + count: 1, + next: null, + previous: null, + results: [attentionParticipant()], + ...overrides, +}); + +export const projectsPage = ( + overrides: Partial = {}, +): ProgramAnalyticsAttentionProjects => ({ + count: 1, + next: null, + previous: null, + results: [attentionProject()], + mode: "distributed", + ...overrides, +}); + +export const notSubmittedProject = ( + overrides: Partial = {}, +): ProgramAnalyticsNotSubmittedProject => ({ + programProjectId: 70, + project: { id: 55, name: "Проект А" }, + leader: { userId: 123, fullName: "Анна Петрова", avatar: null }, + linkedAt: "2026-09-01T10:00:00+03:00", + ...overrides, +}); + +export const notSubmittedPage = ( + overrides: Partial = {}, +): ProgramAnalyticsNotSubmittedProjectsPage => ({ + count: 1, + next: null, + previous: null, + results: [notSubmittedProject()], + applicable: true, + submissionDeadline: "2026-09-10T23:59:00+03:00", + submissionOpen: true, + ...overrides, +}); diff --git a/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts new file mode 100644 index 000000000..d0f2b27d0 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts @@ -0,0 +1,69 @@ +/** @format */ +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProgramEvaluationMode } from "./program-analytics.model"; + +/** Серверный поиск и пагинация: UI не фильтрует одну страницу как полный набор. */ +export interface ProgramAnalyticsAttentionQuery { + search?: string; + limit?: number; + offset?: number; +} + +/** Контракт ApiPagination с фактическими nullable ссылками DRF, без изменения общего типа. */ +export type ProgramAnalyticsAttentionPage = Omit, "next" | "previous"> & { + next: string | null; + previous: string | null; +}; + +/** Уникальный участник без команды в этой программе, не признак «ищет команду». + * registeredAt — регистрация в программе, city сохраняет legacy-значения backend. + */ +export interface ProgramAnalyticsAttentionParticipant { + userId: number; + fullName: string; + avatar: string | null; + city: string | null; + registeredAt: string | null; +} + +/** Стабильные причины backend; пороги и завершённость не пересчитываются на frontend. */ +export type ProgramAnalyticsAttentionReason = + | "no_assignments" + | "no_completed_evaluations" + | "partially_evaluated" + | "awaiting_first_evaluation"; + +/** Одна сданная работа программы, не строка назначения эксперта. + * В open assignment-счётчики null; неизвестная дата сдачи не подменяется другой датой. + */ +export interface ProgramAnalyticsAttentionProject { + programProjectId: number; + project: { id: number; name: string }; + leader: { userId: number; fullName: string; avatar: string | null } | null; + submittedAt: string | null; + status: "awaiting_evaluation" | "partially_evaluated"; + reason: ProgramAnalyticsAttentionReason; + reasonLabel: string; + assignmentsTotal: number | null; + assignmentsCompleted: number | null; +} + +/** Режим берём из ответа списка, не из потенциально устаревшей сводки. */ +export type ProgramAnalyticsAttentionProjects = + ProgramAnalyticsAttentionPage & { mode: ProgramEvaluationMode }; + +/** Несданная связь проекта с программой; linkedAt — дата связи, не создания проекта. */ +export interface ProgramAnalyticsNotSubmittedProject { + programProjectId: number; + project: { id: number; name: string }; + leader: { userId: number; fullName: string; avatar: string | null } | null; + linkedAt: string; +} + +/** Применимость и срок задаёт backend; клиент не пересчитывает дедлайн или SLA. */ +export type ProgramAnalyticsNotSubmittedProjectsPage = + ProgramAnalyticsAttentionPage & { + applicable: boolean; + submissionDeadline: string | null; + submissionOpen: boolean; + }; diff --git a/projects/social_platform/src/app/domain/program/program-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-analytics.model.ts new file mode 100644 index 000000000..f1a7438a8 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-analytics.model.ts @@ -0,0 +1,175 @@ +/** @format */ + +export type ProgramEvaluationMode = "open" | "distributed"; + +export interface ProgramAnalyticsOverview { + summary: { + participants: ProgramAnalyticsTotal; + projects: ProgramAnalyticsTotal; + experts: ProgramAnalyticsTotal; + regions: ProgramAnalyticsRegions; + participantRegions: ProgramAnalyticsRegions; + }; + participantFunnel: { + registrations: number; + uniqueParticipants: number; + withTeam: number; + projectCreators: number; + submittedProjectCreators: number; + }; + solutionFunnel: { + created: number; + notSubmitted: number; + submitted: number; + evaluated: number; + }; + evaluationStatus: { + mode: ProgramEvaluationMode; + maxEvaluationsPerProject: number | null; + assignments: { + total: number; + pending: number; + evaluated: number; + }; + projects: { + submitted: number; + awaitingEvaluation: number; + partiallyEvaluated: number; + evaluated: number; + }; + }; + attention: { + participantsWithoutTeam: number; + projectsAwaitingEvaluation: number; + /** Несданные связи: требование сдачи применимо только к конкурсной программе. */ + projectsNotSubmitted: { applicable: boolean; total: number }; + delayedExperts: ProgramAnalyticsDelayedExperts; + }; + activity: ProgramAnalyticsActivityPoint[]; + cases: ProgramAnalyticsCases; +} + +/** Backend counts Project × Program links; participants are unique within each case, + * not across cases. Submission counts use the link's raw submitted flag. + */ +export interface ProgramAnalyticsCaseMetrics { + participantsTotal: number; + projectsTotal: number; + notSubmitted: number; + submitted: number; +} + +export interface ProgramAnalyticsCaseItem extends ProgramAnalyticsCaseMetrics { + name: string; +} + +/** Current system-case options in backend order, including zero rows. + * withoutCase includes missing/obsolete choices; submissionApplicable controls only UI split. + */ +export interface ProgramAnalyticsCases { + configured: boolean; + submissionApplicable: boolean; + items: ProgramAnalyticsCaseItem[]; + withoutCase: ProgramAnalyticsCaseMetrics; +} + +export interface ProgramAnalyticsTotal { + total: number; +} + +export interface ProgramAnalyticsRegion { + name: string; + count: number; +} + +export interface ProgramAnalyticsRegions extends ProgramAnalyticsTotal { + items: ProgramAnalyticsRegion[]; +} + +export interface ProgramAnalyticsActivityPoint { + date: string; + registrations: number; + submittedSolutions: number; +} + +export interface ProgramAnalyticsError { + kind: "forbidden" | "not_found" | "unauthorized" | "network"; +} + +/** not_ready — проект не сдан; completed — эксперт заполнил все критерии. + * pending/in_progress различаются наличием оценённых критериев, не длительностью ожидания. + */ +export type ProgramAnalyticsAssignmentStatus = + | "not_ready" + | "pending" + | "in_progress" + | "completed"; + +/** pending включает все незавершённые назначения, в том числе not_ready. */ +export type ProgramAnalyticsAssignmentScope = "all" | "completed" | "pending"; + +/** Безопасная идентичность эксперта из manager API, без контактных/auth-полей. */ +export interface ProgramAnalyticsAssignmentExpert { + expertId: number; + userId: number; + firstName: string; + lastName: string; + fullName: string; + avatar: string | null; +} + +export interface ProgramAnalyticsAssignmentProject { + id: number; + name: string; +} + +/** Ожидание вычисляет backend на момент запроса. null не означает нулевое ожидание. */ +export interface ProgramAnalyticsAssignment { + assignmentId: number; + expert: ProgramAnalyticsAssignmentExpert; + project: ProgramAnalyticsAssignmentProject; + status: ProgramAnalyticsAssignmentStatus; + criteriaTotal: number; + criteriaScored: number; + assignedAt: string; + projectSubmitted: boolean; + projectSubmittedAt: string | null; + waitingSince: string | null; + waitingSeconds: number | null; +} + +/** isScored означает наличие записи оценки, включая запись с пустым value. */ +export interface ProgramAnalyticsAssignmentCriterion { + criterionId: number; + name: string; + description: string | null; + type: string; + minValue: number | null; + maxValue: number | null; + value: string | null; + isScored: boolean; +} + +/** Все критерии программы без frontend-агрегации баллов. */ +export interface ProgramAnalyticsAssignmentScoreDetail extends ProgramAnalyticsAssignment { + scores: ProgramAnalyticsAssignmentCriterion[]; +} + +/** Severity и порядок authoritative: warning — >=2 ожиданий 24ч, critical — >=1 48ч. + * Заглавная H соответствует преобразованию overdue_24h/overdue_48h interceptor-ом. + */ +export interface ProgramAnalyticsDelayedExpert extends ProgramAnalyticsAssignmentExpert { + assignmentsTotal: number; + completed: number; + pending: number; + overdue24H: number; + overdue48H: number; + oldestWaitingSince: string; + oldestWaitingSeconds: number; + severity: "warning" | "critical"; +} + +/** В open-режиме backend возвращает пустую группу; UI не синтезирует задержки. */ +export interface ProgramAnalyticsDelayedExperts extends ProgramAnalyticsTotal { + items: ProgramAnalyticsDelayedExpert[]; +} diff --git a/projects/social_platform/src/app/domain/program/program-case-field.const.ts b/projects/social_platform/src/app/domain/program/program-case-field.const.ts new file mode 100644 index 000000000..c066dd085 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-case-field.const.ts @@ -0,0 +1,4 @@ +/** @format */ + +/** The system case is identified only by its exact backend field name. */ +export const PROGRAM_CASE_FIELD_NAME = "case"; diff --git a/projects/social_platform/src/app/domain/program/program.model.ts b/projects/social_platform/src/app/domain/program/program.model.ts index c3646cb16..9a01a9e43 100644 --- a/projects/social_platform/src/app/domain/program/program.model.ts +++ b/projects/social_platform/src/app/domain/program/program.model.ts @@ -1,5 +1,11 @@ /** @format */ +export interface ProgramCurrentApplication { + projectId: number; + programLinkId: number; + submitted: boolean; +} + /** Основная модель программы */ export class Program { id!: number; @@ -26,6 +32,7 @@ export class Program { isUserLiked!: boolean; isUserManager!: boolean; isUserMember!: boolean; + currentProjectApplication!: ProgramCurrentApplication | null; welcomeAcknowledgedAt!: string | null; publishProjectsAfterFinish!: boolean; courseId!: number | null; @@ -56,6 +63,7 @@ export class Program { year: 0, isUserLiked: false, isUserMember: false, + currentProjectApplication: null, welcomeAcknowledgedAt: null, isUserManager: false, publishProjectsAfterFinish: false, diff --git a/projects/social_platform/src/app/domain/project/ports/project-program.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-program.repository.port.ts index e60fb0214..03e645877 100644 --- a/projects/social_platform/src/app/domain/project/ports/project-program.repository.port.ts +++ b/projects/social_platform/src/app/domain/project/ports/project-program.repository.port.ts @@ -2,7 +2,7 @@ import { Observable } from "rxjs"; import { ProjectAssign } from "../project-assign.model"; -import { Project } from "../project.model"; +import { ProgramLinkFields } from "../program-link-fields.model"; import { ProjectNewAdditionalProgramFields } from "../../program/partner-program-fields.model"; /** Порт связи проект↔программа: подача проекта и отправка доп. полей программы. */ @@ -12,8 +12,10 @@ export abstract class ProjectProgramRepositoryPort { partnerProgramId: number, ): Observable; - abstract sendNewProjectFieldsValues( - projectId: number, + abstract getProgramLinkFields(programLinkId: number): Observable; + + abstract updateProgramLinkFields( + programLinkId: number, newValues: ProjectNewAdditionalProgramFields[], - ): Observable; + ): Observable; } diff --git a/projects/social_platform/src/app/domain/project/program-link-fields.fixture.ts b/projects/social_platform/src/app/domain/project/program-link-fields.fixture.ts new file mode 100644 index 000000000..ce5521489 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/program-link-fields.fixture.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { ProgramLinkFields } from "./program-link-fields.model"; +import { PROGRAM_CASE_FIELD_NAME } from "@domain/program/program-case-field.const"; + +export function programLinkFields(overrides: Partial = {}): ProgramLinkFields { + return { + programLinkId: 700, + programId: 12, + projectId: 55, + submitted: false, + isCompetitive: true, + submissionOpen: true, + submissionDeadline: "2026-10-01T18:00:00Z", + canSubmit: true, + fields: [ + { + id: 5, + name: PROGRAM_CASE_FIELD_NAME, + label: "Кейс программы", + fieldType: "select", + options: ["A", "B"], + isRequired: true, + showFilter: true, + helpText: "Подробнее о кейсах", + value: null, + }, + { + id: 6, + name: "track", + label: "Кейс", + fieldType: "select", + options: ["X", "Y"], + isRequired: false, + helpText: "Направление", + value: "Y", + }, + { + id: 7, + name: "note", + label: "Описание", + fieldType: "text", + options: [], + isRequired: false, + helpText: "", + value: null, + }, + { + id: 8, + name: "agree", + label: "Согласие", + fieldType: "checkbox", + options: [], + isRequired: false, + helpText: "", + value: null, + }, + ], + ...overrides, + }; +} diff --git a/projects/social_platform/src/app/domain/project/program-link-fields.model.ts b/projects/social_platform/src/app/domain/project/program-link-fields.model.ts new file mode 100644 index 000000000..683c03d1d --- /dev/null +++ b/projects/social_platform/src/app/domain/project/program-link-fields.model.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; + +export class ProgramLinkField extends PartnerProgramFields { + value!: string | null; +} + +/** Authoritative definitions and saved values for one Project × Program relation. */ +export class ProgramLinkFields { + programLinkId!: number; + programId!: number; + projectId!: number; + submitted!: boolean; + /** Submission metadata belongs to this exact link and is computed by the backend. */ + isCompetitive!: boolean; + submissionOpen!: boolean; + submissionDeadline!: string | null; + canSubmit!: boolean; + + fields!: ProgramLinkField[]; +} + +/** Controlled failures of link-scoped fields/save/submit operations. */ +export interface ProgramLinkFieldsError { + kind: + | "case_required" + | "case_unavailable" + | "submission_closed" + | "already_submitted" + | "not_competitive" + | "network" + | "server" + | "unknown" + | "context"; + cause?: unknown; +} diff --git a/projects/social_platform/src/app/domain/project/project-rate.ts b/projects/social_platform/src/app/domain/project/project-rate.ts index 8ce88f912..db89470c5 100644 --- a/projects/social_platform/src/app/domain/project/project-rate.ts +++ b/projects/social_platform/src/app/domain/project/project-rate.ts @@ -1,6 +1,5 @@ /** @format */ -import { User } from "@domain/auth/user.model"; import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming this is where ProjectRatingCriterion is declared /** Интерфейс проекта для оценки */ @@ -15,8 +14,7 @@ export interface ProjectRate { viewsCount: number; industry: number; scored: boolean; - scoredExpertId: number | null; - ratedExperts: User[]; + ratedExperts: number[]; ratedCount: number; maxRates: number; criterias: ProjectRatingCriterion[]; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts index 2f806feac..a069b028c 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts @@ -10,6 +10,7 @@ import { ProgramCreate } from "@domain/program/program-create.model"; import { Project } from "@domain/project/project.model"; import { ProjectAdditionalFields } from "@domain/project/project-additional-fields.model"; import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { ProgramAnalyticsOverview } from "@domain/program/program-analytics.model"; describe("ProgramHttpAdapter", () => { let adapter: ProgramHttpAdapter; @@ -53,6 +54,36 @@ describe("ProgramHttpAdapter", () => { expect(api.get).toHaveBeenCalledExactlyOnceWith("/programs/5/"); }); + it.each(["all", "completed", "pending"] as const)( + "назначения: scope=%s передаётся query-параметром", + scope => { + setup(); + api.get.mockReturnValue(of([])); + adapter.getManagerAssignments(12, scope).subscribe(); + expect(api.get.mock.lastCall[0]).toBe("/programs/12/project-analytics/assignments/"); + expect(api.get.mock.lastCall[1].toString()).toBe(`scope=${scope}`); + }, + ); + + it("запрашивает критерии назначения внутри программы", () => { + setup(); + api.get.mockReturnValue(of({})); + adapter.getManagerAssignmentScores(12, 17).subscribe(); + expect(api.get).toHaveBeenCalledExactlyOnceWith( + "/programs/12/project-analytics/assignments/17/scores/", + ); + }); + + it("getManagerOverview идёт в production project analytics программы", () => { + setup(); + api.get.mockReturnValue(of({} as ProgramAnalyticsOverview)); + + adapter.getManagerOverview(5).subscribe(); + + expect(api.get).toHaveBeenCalledExactlyOnceWith("/programs/5/project-analytics/"); + expect(api.get.mock.calls.flat().join(" ")).not.toContain("manager-overview"); + }); + it("create идёт в POST /programs/ с телом", () => { setup(); api.post.mockReturnValue(of({} as Program)); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts index a7364e321..729af936c 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -13,6 +13,19 @@ import { Project } from "@domain/project/project.model"; import { ProjectAdditionalFields } from "@domain/project/project-additional-fields.model"; import { ApplyToProgramDTO } from "@domain/program/dto/apply-to-program.model"; import { ApplyToProgramResponse } from "@domain/program/results/apply-to-program"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { + ProgramAnalyticsOverview, + ProgramAnalyticsAssignment, + ProgramAnalyticsAssignmentScope, + ProgramAnalyticsAssignmentScoreDetail, +} from "@domain/program/program-analytics.model"; /** HTTP-адаптер программ: `/programs`, `/auth/public-users` (детали, проекты, участники, фильтры, регистрация). */ @Injectable({ providedIn: "root" }) @@ -40,6 +53,71 @@ export class ProgramHttpAdapter { return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/`); } + getManagerOverview(programId: number): Observable { + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/project-analytics/`); + } + + /** Без автозагрузки остальных страниц; camelcase преобразует общий interceptor. */ + getManagerParticipantsWithoutTeam( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/project-analytics/participants-without-team/`, + this.attentionParams(query), + ); + } + + /** Список работ не подменяется assignments и не включает несданные проекты. */ + getManagerProjectsAwaitingEvaluation( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/project-analytics/projects-awaiting-evaluation/`, + this.attentionParams(query), + ); + } + + /** Отдельный список несданных связей; camelcase выполняет общий interceptor. */ + getManagerProjectsNotSubmitted( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/project-analytics/projects-not-submitted/`, + this.attentionParams(query), + ); + } + + private attentionParams(query: ProgramAnalyticsAttentionQuery): HttpParams { + let params = new HttpParams().set("limit", query.limit ?? 25).set("offset", query.offset ?? 0); + const search = query.search?.trim(); + if (search) params = params.set("search", search); + return params; + } + + /** Query scope передаётся backend без переопределения его статусной семантики. */ + getManagerAssignments( + programId: number, + scope: ProgramAnalyticsAssignmentScope, + ): Observable { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/project-analytics/assignments/`, + new HttpParams().set("scope", scope), + ); + } + + /** Контракт проходит через общий CamelcaseInterceptor. */ + getManagerAssignmentScores( + programId: number, + assignmentId: number, + ): Observable { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/project-analytics/assignments/${assignmentId}/scores/`, + ); + } + acknowledgeWelcome(programId: number): Observable<{ welcomeAcknowledgedAt: string }> { return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/acknowledge-welcome/`, {}); } diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/program-link-fields.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/program-link-fields.dto.ts new file mode 100644 index 000000000..377842c23 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/program-link-fields.dto.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; + +/** Keys are already transformed by the global CamelcaseInterceptor. */ +export interface ProgramLinkFieldsDto { + programLinkId: number; + programId: number; + projectId: number; + submitted: boolean; + isCompetitive: boolean; + submissionOpen: boolean; + submissionDeadline: string | null; + canSubmit: boolean; + fields: (Omit & { + helpText: string | null; + value: string | null; + })[]; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.spec.ts index a8345fbd7..3c0f61544 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.spec.ts @@ -5,15 +5,14 @@ import { of } from "rxjs"; import { ApiService } from "@corelib"; import { ProjectProgramHttpAdapter } from "./project-program-http.adapter"; import { ProjectAssign } from "@domain/project/project-assign.model"; -import { ProjectDto } from "./dto/project.dto"; -import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; +import { programLinkFields } from "@domain/project/program-link-fields.fixture"; describe("ProjectProgramHttpAdapter", () => { let adapter: ProjectProgramHttpAdapter; let api: any; function setup(): void { - api = { post: vi.fn(), put: vi.fn() }; + api = { post: vi.fn(), put: vi.fn(), get: vi.fn() }; TestBed.configureTestingModule({ providers: [ProjectProgramHttpAdapter, { provide: ApiService, useValue: api }], }); @@ -32,13 +31,27 @@ describe("ProjectProgramHttpAdapter", () => { }); }); - it("sendNewProjectFieldsValues идёт в PUT /projects/:id/program-fields/ c массивом", () => { + it("GET uses canonical link 700, not project 55", () => { setup(); - api.put.mockReturnValue(of({} as ProjectDto)); - const values = [] as ProjectNewAdditionalProgramFields[]; + api.get.mockReturnValue(of(programLinkFields())); + adapter.getProgramLinkFields(700).subscribe(); + expect(api.get).toHaveBeenCalledExactlyOnceWith( + "/programs/partner-program-projects/700/fields/", + ); + }); + + it("PUT uses canonical link 700 and field_id/value_text", () => { + setup(); + api.put.mockReturnValue(of({ detail: "Значения успешно обновлены" })); + const values = [{ fieldId: 5, valueText: "Цифровой HR" }]; - adapter.sendNewProjectFieldsValues(42, values).subscribe(); + adapter + .updateProgramLinkFields(700, values) + .subscribe(result => expect(result).toBeUndefined()); - expect(api.put).toHaveBeenCalledExactlyOnceWith("/projects/42/program-fields/", values); + expect(api.put).toHaveBeenCalledExactlyOnceWith( + "/programs/partner-program-projects/700/fields/", + [{ field_id: 5, value_text: "Цифровой HR" }], + ); }); }); diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.ts index 3b2f034d5..c6de637cf 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.ts @@ -2,9 +2,9 @@ import { inject, Injectable } from "@angular/core"; import { ApiService } from "@corelib"; -import { Observable } from "rxjs"; +import { map, Observable } from "rxjs"; import { ProjectAssign } from "@domain/project/project-assign.model"; -import { ProjectDto } from "./dto/project.dto"; +import { ProgramLinkFieldsDto } from "./dto/program-link-fields.dto"; import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; /** HTTP-адаптер связи проект↔программа: подача проекта и доп. поля программы. */ @@ -20,14 +20,20 @@ export class ProjectProgramHttpAdapter { }); } - sendNewProjectFieldsValues( - projectId: number, + getProgramLinkFields(programLinkId: number): Observable { + return this.apiService.get(`/programs/partner-program-projects/${programLinkId}/fields/`); + } + + updateProgramLinkFields( + programLinkId: number, newValues: ProjectNewAdditionalProgramFields[], - ): Observable { + ): Observable { const payload = newValues.map(({ fieldId, valueText }) => ({ field_id: fieldId, value_text: valueText, })); - return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/program-fields/`, payload); + return this.apiService + .put(`/programs/partner-program-projects/${programLinkId}/fields/`, payload) + .pipe(map(() => undefined)); } } diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts index 1182a254b..ce13d5ea9 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts @@ -1,7 +1,7 @@ /** @format */ import { TestBed } from "@angular/core/testing"; -import { of } from "rxjs"; +import { firstValueFrom, of, Subject } from "rxjs"; import { HttpParams } from "@angular/common/http"; import { ProgramRepository } from "./program.repository"; import { ProgramHttpAdapter } from "../../adapters/program/program-http.adapter"; @@ -12,6 +12,7 @@ import { User } from "@domain/auth/user.model"; import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; import { ProjectAdditionalFields } from "@domain/project/project-additional-fields.model"; import { ProgramCreate } from "@domain/program/program-create.model"; +import { ProgramAnalyticsOverview } from "@domain/program/program-analytics.model"; describe("ProgramRepository", () => { let repository: ProgramRepository; @@ -21,6 +22,9 @@ describe("ProgramRepository", () => { adapter = { getAll: vi.fn(), getOne: vi.fn(), + getManagerOverview: vi.fn(), + getManagerAssignments: vi.fn(), + getManagerAssignmentScores: vi.fn(), create: vi.fn(), getDataSchema: vi.fn(), register: vi.fn(), @@ -54,6 +58,18 @@ describe("ProgramRepository", () => { expect(adapter.getAll).toHaveBeenCalledExactlyOnceWith(0, 10, params); }); + it("drilldown делегирует scope/id без кеширования manager данных", () => { + setup(); + adapter.getManagerAssignments.mockReturnValue(of([])); + adapter.getManagerAssignmentScores.mockReturnValue(of({})); + repository.getManagerAssignments(12, "pending").subscribe(); + repository.getManagerAssignments(12, "pending").subscribe(); + repository.getManagerAssignmentScores(12, 17).subscribe(); + expect(adapter.getManagerAssignments).toHaveBeenCalledWith(12, "pending"); + expect(adapter.getManagerAssignments).toHaveBeenCalledTimes(2); + expect(adapter.getManagerAssignmentScores).toHaveBeenCalledExactlyOnceWith(12, 17); + }); + it("getOne кеширует результат: повторный вызов не бьёт adapter", () => { setup(); adapter.getOne.mockReturnValue(of({ id: 42 } as Program)); @@ -64,6 +80,15 @@ describe("ProgramRepository", () => { expect(adapter.getOne).toHaveBeenCalledTimes(1); }); + it("getManagerOverview делегирует в adapter", () => { + setup(); + adapter.getManagerOverview.mockReturnValue(of({} as ProgramAnalyticsOverview)); + + repository.getManagerOverview(42).subscribe(); + + expect(adapter.getManagerOverview).toHaveBeenCalledExactlyOnceWith(42); + }); + it("create делегирует в adapter", () => { setup(); adapter.create.mockReturnValue(of({ id: 1 } as Program)); @@ -106,6 +131,83 @@ describe("ProgramRepository", () => { expect(adapter.getOne).toHaveBeenCalledTimes(2); }); + it("string detail key is invalidated by numeric welcome acknowledgement before TTL", async () => { + setup(); + const runtimeId = "7" as unknown as number; + const stale = { ...Program.default(), id: 7, welcomeAcknowledgedAt: null }; + const fresh = { ...stale, welcomeAcknowledgedAt: "2026-09-07T10:00:00Z" }; + adapter.getOne.mockReturnValueOnce(of(stale)).mockReturnValueOnce(of(fresh)); + adapter.acknowledgeWelcome.mockReturnValue( + of({ welcomeAcknowledgedAt: fresh.welcomeAcknowledgedAt }), + ); + + expect(await firstValueFrom(repository.getOne(runtimeId))).toBe(stale); + expect(adapter.getOne).toHaveBeenCalledTimes(1); + await firstValueFrom(repository.acknowledgeWelcome(7)); + expect(await firstValueFrom(repository.getOne(runtimeId))).toBe(fresh); + expect(adapter.getOne).toHaveBeenCalledTimes(2); + expect(adapter.getOne.mock.calls).toEqual([[7], [7]]); + expect(adapter.acknowledgeWelcome).toHaveBeenCalledExactlyOnceWith(7); + }); + + it("string and number share one cache key; acknowledging 7 preserves cached program 8", async () => { + setup(); + const seventh = { ...Program.default(), id: 7 }; + const eighth = { ...Program.default(), id: 8 }; + adapter.getOne.mockImplementation((id: number) => of(id === 7 ? seventh : eighth)); + adapter.acknowledgeWelcome.mockReturnValue( + of({ welcomeAcknowledgedAt: "2026-09-07T10:00:00Z" }), + ); + + await firstValueFrom(repository.getOne("7" as unknown as number)); + expect(await firstValueFrom(repository.getOne(7))).toBe(seventh); + await firstValueFrom(repository.getOne("8" as unknown as number)); + expect(adapter.getOne.mock.calls).toEqual([[7], [8]]); + await firstValueFrom(repository.acknowledgeWelcome("7" as unknown as number)); + await firstValueFrom(repository.getOne(7)); + expect(await firstValueFrom(repository.getOne(8))).toBe(eighth); + expect(adapter.getOne.mock.calls).toEqual([[7], [8], [7]]); + expect(adapter.acknowledgeWelcome).toHaveBeenCalledExactlyOnceWith(7); + }); + + it("does not invalidate before POST success or after an error; permits retry", async () => { + setup(); + const program = { ...Program.default(), id: 7, welcomeAcknowledgedAt: null }; + const response = new Subject<{ welcomeAcknowledgedAt: string }>(); + adapter.getOne.mockReturnValue(of(program)); + adapter.acknowledgeWelcome + .mockReturnValueOnce(response) + .mockReturnValueOnce(of({ welcomeAcknowledgedAt: "2026-09-07T10:00:00Z" })); + await firstValueFrom(repository.getOne("7" as unknown as number)); + const error = vi.fn(); + repository.acknowledgeWelcome(7).subscribe({ error }); + expect(await firstValueFrom(repository.getOne(7))).toBe(program); + expect(adapter.getOne).toHaveBeenCalledTimes(1); + response.error(new Error("POST failed")); + expect(error).toHaveBeenCalledOnce(); + expect(await firstValueFrom(repository.getOne(7))).toBe(program); + expect(adapter.getOne).toHaveBeenCalledTimes(1); + expect(program.welcomeAcknowledgedAt).toBeNull(); + + await firstValueFrom(repository.acknowledgeWelcome(7)); + await firstValueFrom(repository.getOne("7" as unknown as number)); + expect(adapter.getOne).toHaveBeenCalledTimes(2); + }); + + it.each([NaN, 0, -7, 1.5, Infinity, "abc", "0", "-7", "1.5", "", undefined, null])( + "rejects invalid runtime id %s through Observable errors without HTTP", + async invalidId => { + setup(); + const id = invalidId as number; + const detail = repository.getOne(id); + const acknowledgement = repository.acknowledgeWelcome(id); + await expect(firstValueFrom(detail)).rejects.toBeInstanceOf(Error); + await expect(firstValueFrom(acknowledgement)).rejects.toBeInstanceOf(Error); + expect(adapter.getOne).not.toHaveBeenCalled(); + expect(adapter.acknowledgeWelcome).not.toHaveBeenCalled(); + }, + ); + it("getAllProjects делегирует в adapter", () => { setup(); const params = new HttpParams(); @@ -135,12 +237,18 @@ describe("ProgramRepository", () => { expect(adapter.getProgramProjectAdditionalFields).toHaveBeenCalledExactlyOnceWith(1); }); - it("applyProjectToProgram делегирует в adapter", () => { + it("applyProjectToProgram делегирует в adapter и после успеха сбрасывает кеш программы", () => { setup(); const dto = { project: {} as Project, programFieldValues: [] }; + adapter.getOne.mockReturnValue(of({ ...Program.default(), id: 1 })); adapter.applyProjectToProgram.mockReturnValue(of({ projectId: 1, programLinkId: 2 })); + + repository.getOne(1).subscribe(); repository.applyProjectToProgram(1, dto).subscribe(); + repository.getOne(1).subscribe(); + expect(adapter.applyProjectToProgram).toHaveBeenCalledExactlyOnceWith(1, dto); + expect(adapter.getOne).toHaveBeenCalledTimes(2); }); it("createProgramFilters делегирует в adapter", () => { diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index 89a009149..cb07aa6b5 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -2,7 +2,7 @@ import { HttpParams } from "@angular/common/http"; import { inject, Injectable } from "@angular/core"; -import { map, Observable, tap } from "rxjs"; +import { map, Observable, tap, throwError } from "rxjs"; import { User } from "@domain/auth/user.model"; import { ApiPagination } from "@domain/other/api-pagination.model"; import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; @@ -18,6 +18,19 @@ import { ApplyToProgramResponse } from "@domain/program/results/apply-to-program import { userFromRaw } from "@utils/userRaw"; import { EventBus } from "@domain/shared/event-bus"; import { LoggedOut } from "@domain/auth/events/logged-out.event"; +import { + ProgramAnalyticsAttentionPage, + ProgramAnalyticsAttentionParticipant, + ProgramAnalyticsAttentionProjects, + ProgramAnalyticsNotSubmittedProjectsPage, + ProgramAnalyticsAttentionQuery, +} from "@domain/program/program-analytics-attention.model"; +import { + ProgramAnalyticsOverview, + ProgramAnalyticsAssignment, + ProgramAnalyticsAssignmentScope, + ProgramAnalyticsAssignmentScoreDetail, +} from "@domain/program/program-analytics.model"; /** Репозиторий программ: `EntityCache` для `getOne`, остальное — passthrough. */ @Injectable({ providedIn: "root" }) @@ -43,13 +56,69 @@ export class ProgramRepository implements ProgramRepositoryPort { } getOne(programId: number): Observable { - return this.entityCache.getOrFetch(programId, () => this.programAdapter.getOne(programId)); + const id = this.normalizeProgramId(programId); + if (id === null) return throwError(() => new RangeError("Invalid program id")); + return this.entityCache.getOrFetch(id, () => this.programAdapter.getOne(id)); + } + + getManagerOverview(programId: number): Observable { + return this.programAdapter.getManagerOverview(programId); + } + + /** Свежий manager-список при каждом открытии/поиске, без EntityCache. */ + getManagerParticipantsWithoutTeam( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable> { + return this.programAdapter.getManagerParticipantsWithoutTeam(programId, query); + } + + /** Проксируем актуальный count/mode и nullable поля без вычислений. */ + getManagerProjectsAwaitingEvaluation( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.programAdapter.getManagerProjectsAwaitingEvaluation(programId, query); + } + + /** Передаёт страницу несданных связей без кэша, нормализации или пересчёта срока. */ + getManagerProjectsNotSubmitted( + programId: number, + query: ProgramAnalyticsAttentionQuery, + ): Observable { + return this.programAdapter.getManagerProjectsNotSubmitted(programId, query); + } + + /** Не кешируем manager-данные между открытиями или программами. */ + getManagerAssignments( + programId: number, + scope: ProgramAnalyticsAssignmentScope, + ): Observable { + return this.programAdapter.getManagerAssignments(programId, scope); + } + + /** Возвращает все критерии, включая ещё не оценённые. */ + getManagerAssignmentScores( + programId: number, + assignmentId: number, + ): Observable { + return this.programAdapter.getManagerAssignmentScores(programId, assignmentId); } acknowledgeWelcome(programId: number): Observable<{ welcomeAcknowledgedAt: string }> { + const id = this.normalizeProgramId(programId); + if (id === null) return throwError(() => new RangeError("Invalid program id")); return this.programAdapter - .acknowledgeWelcome(programId) - .pipe(tap(() => this.entityCache.invalidate(programId))); + .acknowledgeWelcome(id) + .pipe(tap(() => this.entityCache.invalidate(id))); + } + + /** Route callers may pass strings at runtime. Detail cache reads and welcome + * invalidation must use the same numeric key; invalid IDs become Observable errors. + */ + private normalizeProgramId(programId: number): number | null { + const id = Number(programId); + return Number.isInteger(id) && id > 0 ? id : null; } create(program: ProgramCreate): Observable { @@ -92,7 +161,9 @@ export class ProgramRepository implements ProgramRepositoryPort { programId: number, dto: ApplyToProgramDTO, ): Observable { - return this.programAdapter.applyProjectToProgram(programId, dto); + return this.programAdapter + .applyProjectToProgram(programId, dto) + .pipe(tap(() => this.entityCache.invalidate(programId))); } createProgramFilters( diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.spec.ts index c6f3aed40..070072cf4 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.spec.ts @@ -5,8 +5,8 @@ import { of } from "rxjs"; import { ProjectProgramRepository } from "./project-program.repository"; import { ProjectProgramHttpAdapter } from "../../adapters/project/project-program-http.adapter"; import { ProjectAssign } from "@domain/project/project-assign.model"; -import { Project } from "@domain/project/project.model"; -import { ProjectDto } from "../../adapters/project/dto/project.dto"; +import { ProgramLinkField, ProgramLinkFields } from "@domain/project/program-link-fields.model"; +import { programLinkFields } from "@domain/project/program-link-fields.fixture"; import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; describe("ProjectProgramRepository", () => { @@ -14,7 +14,11 @@ describe("ProjectProgramRepository", () => { let adapter: any; function setup(): void { - adapter = { assignProjectToProgram: vi.fn(), sendNewProjectFieldsValues: vi.fn() }; + adapter = { + assignProjectToProgram: vi.fn(), + getProgramLinkFields: vi.fn(), + updateProgramLinkFields: vi.fn(), + }; TestBed.configureTestingModule({ providers: [ ProjectProgramRepository, @@ -36,16 +40,50 @@ describe("ProjectProgramRepository", () => { }); })); - it("sendNewProjectFieldsValues мапит ответ в Project", () => + it("GET maps the canonical snapshot and nullable help text", () => { + setup(); + const dto = programLinkFields(); + adapter.getProgramLinkFields.mockReturnValue( + of({ ...dto, fields: [{ ...dto.fields[0], helpText: null }] }), + ); + repository.getProgramLinkFields(700).subscribe(snapshot => { + expect(snapshot).toBeInstanceOf(ProgramLinkFields); + expect(snapshot.fields[0]).toBeInstanceOf(ProgramLinkField); + expect(snapshot.fields[0].helpText).toBe(""); + expect(snapshot.fields[0].value).toBeNull(); + expect(snapshot.projectId).toBe(55); + }); + expect(adapter.getProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700); + }); + + it("PUT delegates canonical relation ID without interpreting acknowledgement as Project", () => new Promise(done => { setup(); const values = [] as ProjectNewAdditionalProgramFields[]; - adapter.sendNewProjectFieldsValues.mockReturnValue(of({ id: 1 } as ProjectDto)); + adapter.updateProgramLinkFields.mockReturnValue(of(undefined)); - repository.sendNewProjectFieldsValues(42, values).subscribe(res => { - expect(adapter.sendNewProjectFieldsValues).toHaveBeenCalledExactlyOnceWith(42, values); - expect(res).toBeInstanceOf(Project); + repository.updateProgramLinkFields(700, values).subscribe(res => { + expect(adapter.updateProgramLinkFields).toHaveBeenCalledExactlyOnceWith(700, values); + expect(res).toBeUndefined(); done(); }); })); + + it.each(["2020-01-01T00:00:00Z", null])( + "preserves canonical metadata, including deadline %s, without frontend calculations", + deadline => { + setup(); + const dto = programLinkFields({ submissionDeadline: deadline }); + adapter.getProgramLinkFields.mockReturnValue(of(dto)); + repository.getProgramLinkFields(700).subscribe(snapshot => { + expect(snapshot).toMatchObject({ + isCompetitive: true, + submissionOpen: true, + submissionDeadline: deadline, + canSubmit: true, + submitted: false, + }); + }); + }, + ); }); diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.ts index ebeb23a75..6b11a67bc 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.ts @@ -5,7 +5,7 @@ import { ProjectProgramHttpAdapter } from "../../adapters/project/project-progra import { map, Observable } from "rxjs"; import { ProjectAssign } from "@domain/project/project-assign.model"; import { plainToInstance } from "class-transformer"; -import { Project } from "@domain/project/project.model"; +import { ProgramLinkField, ProgramLinkFields } from "@domain/project/program-link-fields.model"; import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; @@ -20,12 +20,23 @@ export class ProjectProgramRepository implements ProjectProgramRepositoryPort { .pipe(map(assign => plainToInstance(ProjectAssign, assign))); } - sendNewProjectFieldsValues( - projectId: number, + getProgramLinkFields(programLinkId: number): Observable { + return this.projectProgramAdapter.getProgramLinkFields(programLinkId).pipe( + map(snapshot => + Object.assign(new ProgramLinkFields(), { + ...snapshot, + fields: snapshot.fields.map(field => + Object.assign(new ProgramLinkField(), field, { helpText: field.helpText ?? "" }), + ), + }), + ), + ); + } + + updateProgramLinkFields( + programLinkId: number, newValues: ProjectNewAdditionalProgramFields[], - ): Observable { - return this.projectProgramAdapter - .sendNewProjectFieldsValues(projectId, newValues) - .pipe(map(fields => plainToInstance(Project, fields))); + ): Observable { + return this.projectProgramAdapter.updateProgramLinkFields(programLinkId, newValues); } } diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts index a2e31211a..5322d1ca1 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.spec.ts @@ -82,4 +82,34 @@ describe("ProjectRatingRepository", () => { expect(out).toEqual([{ criterionId: 3, value: "hello" }]); }); + + it("formValuesToDTO всегда отправляет полный backend snapshot критериев", () => { + setup(); + const criteria = [ + { id: 1, type: "int", value: 2 }, + { id: 2, type: "bool", value: "true" }, + { id: 3, type: "str", value: "Existing" }, + ] as ProjectRatingCriterion[]; + + expect(repository.formValuesToDTO(criteria, {})).toEqual([ + { criterionId: 1, value: 2 }, + { criterionId: 2, value: "true" }, + { criterionId: 3, value: "Existing" }, + ]); + }); + + it("formValuesToDTO сохраняет полный набор при изменении одного критерия", () => { + setup(); + const criteria = [ + { id: 1, type: "int", value: 2 }, + { id: 2, type: "bool", value: "false" }, + { id: 3, type: "str", value: "Existing" }, + ] as ProjectRatingCriterion[]; + + expect(repository.formValuesToDTO(criteria, { 1: "5" })).toEqual([ + { criterionId: 1, value: 5 }, + { criterionId: 2, value: "false" }, + { criterionId: 3, value: "Existing" }, + ]); + }); }); diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts index 3bd0d0e0d..2a583c34c 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts @@ -40,24 +40,24 @@ export class ProjectRatingRepository implements ProjectRatingRepositoryPort { criteria: ProjectRatingCriterion[], outputVals: Record, ): ProjectRatingCriterionOutput[] { - const output: ProjectRatingCriterionOutput[] = []; - const normalizedOutputVals = { ...outputVals }; + return criteria.map(criterion => { + const key = String(criterion.id); + let value = Object.prototype.hasOwnProperty.call(outputVals, key) + ? outputVals[key] + : criterion.value; - for (const key in normalizedOutputVals) { // оценки с boolean значением переводятся в "string-boolean" (true => "True") - if (typeof normalizedOutputVals[key] === "boolean") { - const boolString = String(normalizedOutputVals[key]); - normalizedOutputVals[key] = boolString.charAt(0).toUpperCase() + boolString.slice(1); + if (typeof value === "boolean") { + const boolString = String(value); + value = boolString.charAt(0).toUpperCase() + boolString.slice(1); } // оценки с числовым значением из инпута приходят строкой, их нужно привести к number - if (criteria.find(c => c.id === Number(key))?.type === "int") { - normalizedOutputVals[key] = Number(normalizedOutputVals[key]); + if (criterion.type === "int") { + value = Number(value); } - output.push({ criterionId: Number(key), value: normalizedOutputVals[key] }); - } - - return output; + return { criterionId: criterion.id, value }; + }); } } diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html index 8af73ee84..89005389e 100644 --- a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html @@ -3,7 +3,13 @@