diff --git a/angular.json b/angular.json index 0bec9fe41..e35af2f69 100644 --- a/angular.json +++ b/angular.json @@ -65,6 +65,31 @@ "extractLicenses": false, "sourceMap": true, "outputHashing": "all" + }, + "pilot": { + "fileReplacements": [ + { + "replace": "projects/social_platform/src/environments/environment.ts", + "with": "projects/social_platform/src/environments/environment.pilot.ts" + } + ], + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "2mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "15kb" + } + ], + "outputHashing": "all", + "index": { + "input": "projects/social_platform/src/index.pilot.html", + "output": "index.html" + } } }, "defaultConfiguration": "production" diff --git a/docs/contracts/program-solutions-v1/README.md b/docs/contracts/program-solutions-v1/README.md index 1d9a0d6ff..337f2e97a 100644 --- a/docs/contracts/program-solutions-v1/README.md +++ b/docs/contracts/program-solutions-v1/README.md @@ -1,5 +1,7 @@ +Изменение 1.22.0: optional weight_percent у критериев, scoring_mode у результатов и contributions у строки решения. Режим weighted_percent нормализует каждый числовой критерий по его шкале и процентному весу, максимум равен 100; legacy сохраняет прежнюю сумму. Старые payload оценок, required поля, URL и примеры совместимы. Веса и режим входят в revision и фиксируются первой оценкой; правила Project и ProgramSolution используют общую формулу. Для внедрения нужны добавочные миграции программы/критерия и backend до Angular. + # C00 Контракт решения в программе Изменение 1.21.0: публичные GET охватывают уже сданные конкурсные решения КЧ/хакатона после окончания программы и установки marker существующим publisher. Неконкурсные сохраненные материалы остаются без сдачи. DTO/156 schemas/59 operations сохраняются;6 новых независимых сценариев, итого305. Временный сценарий published_solution_list_competitive_pending_rule намеренно меняется404→200 с пустой страницей: весь конкурсный список больше не закрыт из-за отдельного вопроса о draft. Остальные298 старых сценариев структурно неизменны. Несданные конкурсные работы и архивы не выдаются, окончательная eligibility draft все еще ожидает ответа. Нового Angular UI, схемы БД или Project нет. diff --git a/docs/contracts/program-solutions-v1/examples.json b/docs/contracts/program-solutions-v1/examples.json index e0d4745c0..02eb5ff35 100644 --- a/docs/contracts/program-solutions-v1/examples.json +++ b/docs/contracts/program-solutions-v1/examples.json @@ -1,5 +1,5 @@ { - "contract_version": "1.21.0", + "contract_version": "1.22.0", "description": "Независимые примеры, не последовательность операций; фиктивные данные.", "scenarios": [ { @@ -12645,6 +12645,393 @@ "errors": [], "meta": {} } + }, + { + "name": "solution_results_weighted_percent", + "operation_id": "getSolutionResults", + "status": 200, + "response": { + "program_id": 12, + "program_name": "Кейс-чемпионат PROCOLLAB", + "generated_at": "2026-10-24T20:00:00Z", + "required_evaluators": 2, + "maximum": 100, + "has_numeric_criteria": true, + "case_options": [ + "Цифровой сервис" + ], + "groups": [ + { + "case_name": "Цифровой сервис", + "solutions": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "solution_id": 301, + "name": "Сервис для студентов", + "case_name": "Цифровой сервис", + "completed": 1, + "required": 2, + "evaluation_complete": false, + "ready": false, + "score": 54, + "place": null, + "averages": { + "21": 0, + "22": 4.5 + }, + "experts": [ + { + "user_id": 91, + "name": "Эксперт программы", + "email": "expert@local.example", + "avatar": null, + "complete": true, + "total": 54, + "values": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + } + ], + "contributions": { + "21": 0, + "22": 54 + } + } + ] + } + ], + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "weight_percent": null + } + ], + "scoring_mode": "weighted_percent" + } + }, + { + "name": "solution_rating_weighted_percent", + "operation_id": "rateSolution", + "status": 200, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА" + } + ] + }, + "response": { + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "id": 301, + "program_id": 12, + "version": 10, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0", + "weight_percent": 40 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "value": "4.5", + "weight_percent": 60 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False", + "weight_percent": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + "weight_percent": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 2, + "deadline": "2026-10-30T18:00:00Z" + } } ] } diff --git a/docs/contracts/program-solutions-v1/openapi.json b/docs/contracts/program-solutions-v1/openapi.json index c425736cd..913545332 100644 --- a/docs/contracts/program-solutions-v1/openapi.json +++ b/docs/contracts/program-solutions-v1/openapi.json @@ -3,7 +3,7 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "PROCOLLAB Program Solutions C00", - "version": "1.21.0", + "version": "1.22.0", "description": "Контракт решений в программах для реализации и моков BE01–BE09. API base URL задается окружением. snake_case на проводе. Старые API не меняются." }, "servers": [ @@ -11549,6 +11549,15 @@ ], "maxLength": 1000, "description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно." + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12279,6 +12288,15 @@ "number", "null" ] + }, + "weight_percent": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0, + "maximum": 100, + "description": "Процентный вес числового критерия; null, если вес не применяется." } }, "required": [ @@ -12396,6 +12414,12 @@ "items": { "$ref": "#/components/schemas/ResultExpert" } + }, + "contributions": { + "type": "object", + "additionalProperties": { + "type": "number" + } } }, "required": [ @@ -12481,6 +12505,13 @@ "items": { "$ref": "#/components/schemas/ResultCriterion" } + }, + "scoring_mode": { + "type": "string", + "enum": [ + "legacy", + "weighted_percent" + ] } }, "required": [ diff --git a/docs/feature-specs/overall-case-quiz-ranking.md b/docs/feature-specs/overall-case-quiz-ranking.md new file mode 100644 index 000000000..1b3434a76 --- /dev/null +++ b/docs/feature-specs/overall-case-quiz-ranking.md @@ -0,0 +1,181 @@ + + +# Общий рейтинг кейс-чемпионата + +## Решение и границы + +12 ноября 2026 владелец выбрал простое сложение: кейс из 100 плюс квиз из 100, +общий максимум 200. Рейтинг доступен только организатору. Проценты критериев +остаются внутри кейса; долей этапов нет. Квиз и его экран пока не переносим: +дизайн квиза не согласован. + +API spec и O01–O11: +`PROCOLLAB-github/api:docs/overall-case-quiz-ranking.md`, интеграционная ветка +`codex/white-label-foundation-20261110`, PR #861. + +Сервер отдает готовые места, баллы, готовность и статусы. Отсутствующий этап +имеет `null`; настоящий ноль отображается как `0,00`. До завершения обоих +этапов общего итога и места нет. Равные суммы делят место: 1, 1, 3. +Поиск/фильтр не меняют глобальные места. Angular не пересчитывает кейс, +результат квиза, сумму или места и не записывает баллы вручную. + +## Утвержденный экран + +В существующем разделе «Оценивание» добавить пункт «Общий рейтинг» после +«Результатов». Существующие результаты кейса сохраняются. Новый пункт только +для конкурсной программы с `solutionStorageMode === "legacy_project"`; +ProgramSolution не входит в этот контракт. Прямой API независимо проверяет права. + +Pattern: расширение текущего ProgramManagement/Results. References: +`management.component.{ts,html,scss}` и `results/results.component.{ts,html,scss}`. +Figma: экран не создавался, потому что запись в Figma не запрашивалась. +Canonical DS reference: +. +Часть текстовых DS contracts отсутствует в текущем checkout; визуальная +совместимость проверяется по реальным соседним компонентам, Mont и токенам. + +Компоненты: существующие Icon, Button, Field/Choice, card/results layout; +доступная таблица со столбцами «Место», «Команда», «Кейс», «Кейс из 100», +«Квиз из 100», «Итог из 200», «Статус». Поиск по команде, статус, обновление +и Excel. Новых DS primitives нет. Токены текущего экрана: `--accent-text`, +`--grey-for-text`, `--black`, `--medium-grey-for-outline`, `--light-white`. + +Состояния: загрузка с `aria-busy`, пустая программа, нет совпадений, +ошибка с повтором, 403/404, ненастроенный кейс с переходом к критериям, +ожидание одного/обоих этапов, готовый настоящий ноль. Предварительный балл +кейса не должен выглядеть окончательным: статус показывает число полных +экспертных оценок. На узком контейнере строки перестраиваются в читаемые +блоки; видимый порядок совпадает с DOM. Длинное название переносится. +Кнопки имеют доступные названия, поля — labels, клавиатурный фокус виден. + +Конкретные desktop 1440/mobile 390 макеты показаны владельцу 12 ноября. +Статическое preview прошло 9 Chromium checks: Mont, отсутствие page overflow, +поиск с сохранением места, настоящий ноль, длинное название, loading/empty/ +error/unconfigured и keyboard. JS page errors: 0. Эта проверка относилась +только к макету. Проверки реализованного Angular приведены ниже. + +Bounded Impeccable/continuity review: сохранены плотность Results и навигация +ProgramManagement. Исправлены три наблюдавшиеся проблемы: слишком сильный +акцент текстовых refresh/export действий заменен существующими иконками; +активный подпункт на мобильном прокручивается в видимую область; двойная +рамка фокуса поиска устранена без удаления focus state. Layout detector для +preview: 0 findings. Полный critique не запускался. Общая AA-сертификация +по макету не заявляется. + +12 ноября 2026 владелец ответил «да норм» на показанные макеты. Design gate +пройден; выполняется Angular implementation по этому экрану. Квиз требует +своего отдельного согласованного дизайна и не переносится. + +## План реализации + +1. Новый domain `ProgramOverallResults`/row/status по API; не расширять union + Project/ProgramSolution и не смешивать ID связи, проекта и программы. +2. Добавить GET и export в существующие port → repository → use-case → HTTP + adapter, сохранив snake/camel mapping инфраструктуры и `ResultsQuery`. +3. Добавить organizer-only section/deep link в management и отдельный + read-only компонент по согласованному экрану. Сохранять фильтры/query, + отмену устаревших запросов, состояния и server-owned места. +4. Проверить adapter contract/null/zero/filters, component loading/error/ + pending/permissions, navigation и отмену запросов. Релевантные Vitest, + scoped ESLint/Prettier, Angular build и реальные desktop/mobile browser + checks с локальным API; затем review и обновление PR #540. + +Фильтры экрана: `search` и `status` в query params; поиск с debounce, +серверные запросы отменяются при смене фильтра/программы. Excel выгружает +текущую выборку. Ссылка команды использует `projectId` для существующей +страницы проекта; `id` строки остается ID участия. Недоступный контур по +прямой ссылке не запускает запрос общего рейтинга. + +Порядок будущей установки: совместимый API и добавочная миграция → Angular. +На живом пилоте тестовые результаты не создаются; dev/prod не обновляются. + +## Реализация и проверки + +Реализованы domain model, repository port, HTTP adapter, repository/use-case и +`overall-results/overall-results.component.{ts,html,scss}`. Management section +`overall-results` входит в существующее оценивание; в другом контуре пункт +скрыт, прямой переход показывает причину без запроса рейтинга. Старый Results +компонент не изменен. Сумма, места и готовность приходят с сервера. + +Query params сохраняют поиск/статус. `switchMap` отменяет устаревшее чтение; +при смене программы старые строки скрываются, незавершенная выгрузка отменяется. +Пока введенный фильтр еще не применен, прежняя выборка не скачивается. Ошибка +чтения очищает строки и запрещает выгрузку; повтор сохраняет фильтры. + +Checked by tests: **108 Vitest tests, 5 files, PASS**: +`overall-results.component.spec.ts`, `program-management-http.adapter.spec.ts`, +`management.component.spec.ts`, `results.component.spec.ts`, +`program-management.service.spec.ts`. После изменения select на существующий +appField повторены 72 затронутых component/management tests — PASS. +TypeScript solution contract, scoped ESLint, Prettier, Stylelint нового SCSS +и `git diff --check` прошли. + +Checked by build: `npm run build:pr`; Angular `pilot` и `production` builds +прошли. Итоговые pilot/production сборки включают финальные стили и шаблоны. +Есть существующие Sass/CommonJS и budget warnings; ошибок сборки нет. + +Checked in browser: **13 Chromium сценариев, PASS**, compiled pilot + настоящий +локальный Django/PostgreSQL, синтетические программы/пользователи: + +- loading с задержкой настоящего запроса, 7 колонок на desktop 1440, Mont, + 200, настоящий ноль, отсутствие места и итога у ожидающей команды; +- совпадение мест с API, сохранение места при поиске, query params и reload; +- настоящий XLSX текущей выборки, отсутствие совпадений и сброс; +- фильтр ожидающих, длинная команда на mobile 390, активная навигация, + отсутствие overflow страницы/компонента/таблицы и кнопки 44px; +- ширина 820, клавиатурный переход поиска в select и focus state; +- обрыв запросов с очисткой старых строк/export, затем повтор с настоящим API; +- переходы между прежними результатами кейса и новым рейтингом; +- пустая программа, legacy без скрытой нормализации и неподдерживаемый контур; +- посторонний пользователь: нет строк в DOM, прямой API 403; гость уходит + на существующую брендированную форму входа. + +Ошибок JS: 0. Внешние запросы блокировались; зафиксированы 7 попыток загрузки +существующей внешней картинки-заглушки другого виджета. Внешние запросы не +доставлялись; этот виджет не менялся. Ответы рейтинга не подменялись; +fault injection — только задержка/обрыв локальных запросов. Книга из Angular +проверена openpyxl: одна команда, глобальное место 2, 80 + 90, формула суммы, +Arial 10 и A2. Native Excel engine не запускался. + +Review: **O11 PASS**. Новых BLOCKER/MAJOR не осталось. Сохранены текущие +ProgramManagement/Results, Mont, shared Button/Field/Icon/TransientNotice. +В bounded quality pass исправлены три проблемы: преждевременный переход таблицы +в блоки внутри боковых меню, перенос заголовка «Место», локальный select заменен +на appField. Переход к блокам зависит от контейнера 600px; reading order сохранен. +Layout detector: 0 findings. Новых DS primitives нет; полный critique не запускался. + +Task state: **implementation / verification / review complete**; PR #540. +Backend O01–O10 и его PostgreSQL CI прошли в API #861. Публикация на пилоте +подтверждена 13 ноября ниже. Не выполнены интеграция с настоящим квизом, нагрузка 100 участников, +реальный SMTP и приемка клиента. Квиз остается отдельным этапом. +Merge и обновление dev/prod не выполнялись. + +## Приемка опубликованного пилота — 13 ноября 2026 + +Владелец установил runtime `3c21fbc` и эту сборку `3241381e` отдельным +tooling `7a8e6c6`; apply/status дважды подтвердили active, сохраненную БД, +неизменный nginx и dev 200. Backup: `overall-d572cb63a116`. +Все пять измененных frontend файлов проверены по SHA256 через HTTPS. + +**11 Chromium сценариев PASS** на настоящих опубликованных UI/API без подмены +ответов: защищенный deep link и вход организатора, текущий legacy notice, +поиск/статус/URL/reload, 1440/390 и Mont/видимая активная вкладка/44px action, +прежние case results/criteria, ошибка запроса и retry, выход на клиентский вход, +отказ участнику. Также реальные Django admin login/общий рейтинг/XLSX и пустой +readonly список итогов квиза. Ошибок JS нет, изменения программы блокировались; +разрешались только вход/выход. Пять внешних картинок-заглушек блокировались. +Скриншоты и credentials находятся вне Git. + +**10 HTTPS/API checks PASS**: итог 100 + 100, фильтры, полная/выбранная XLSX, +отрицательные GET гостя/участника/эксперта, сохранность прежней команды/сдачи/PDF, +критериев и dev. Admin XLSX совпала с API по ячейкам. После удаления только +транспортного ZIP через QA owner API (204 → 404) еще три проверки подтвердили +сохранность данных/API/UI/dev; release и backup не затрагивались. + +Программа сохраняет `legacy` и текстовый «Комментарий», без числовых критериев +и весов. Экран корректно предлагает настроить кейс из 100, не создает итогов +и отключает Angular Excel. Организатору нужны согласованные числовые критерии +с суммой весов 100%, затем результат настоящего квиза. Готовые 200/0/pending +проверены локально; на живом пилоте тестовые баллы не добавлялись. +Нагрузка 100, настоящий SMTP, native Excel пересчет и приемка клиента остаются +непроверенными. Dev/prod не обновлялись. diff --git a/docs/feature-specs/white-label-registration.md b/docs/feature-specs/white-label-registration.md new file mode 100644 index 000000000..ac0c0d0bd --- /dev/null +++ b/docs/feature-specs/white-label-registration.md @@ -0,0 +1,147 @@ + + +# Короткая регистрация в клиентском экземпляре + +Существующая `/program-registration/:id` получает дополнительный вариант из API. +При `account_mode: "email_password"` новый участник вводит почту, пароль/повтор и +подтверждает прежние согласия. ФИО и дата рождения скрыты и отсутствуют в payload; +обязательность динамических вопросов анкеты остается. Письмо и подтверждение +используют существующий flow, затем открывается программа без общего онбординга. + +`branding: {name, logo_url}` задает имя и логотип формы, входа и заголовка вкладки. +Без логотипа показывается имя клиента. Компоненты, Mont, стили, состояния формы, +ошибки, ссылки, сохранение ответов, подтверждение почты и permissions сохраняются. +Новая страница и новый макет не создаются; квиз не входит в изменения. + +Без новых ключей API форма сохраняет полные поля и бренд PROCOLLAB. Отдельно +TokenService сохраняет production cookies для текущего клиентского host; +домен `.procollab.ru` используется на доменах платформы, ключи dev/prod прежние. + +## Критерии и проверка + +- WL10: скрытые поля профиля отсутствуют в payload, остальные проверки работают. + Component tests: короткая/полная форма, пароли, согласия, вопросы и переход назад. +- WL11: имя/логотип и заголовки зависят от API, обычная форма сохраняет бренд. + Component и HTTP adapter tests проверяют в том числе вложенное snake/camel casing. +- WL12: клиентский host получает собственные production cookies, домены платформы + сохраняют общий domain. TokenService tests включают похожие посторонние домены. + +Проверки 10 ноября 2026: 44 целевых Vitest tests (component, HTTP adapter, +TokenService, AuthRequiredGuard и BearerTokenInterceptor), scoped ESLint/Prettier, +`test:solution-contract`, production Angular build. Полный Vitest suite не запускался. +Сборка проходит с предупреждениями Sass, budgets и CommonJS в существующих файлах. + +В браузере Chromium production сборка работала с настоящим локальным API и +отдельной PostgreSQL. Проверены desktop 1440×1000 и mobile 390×844, регистрация → +локальное письмо → подтверждение → программа → reload с сохранением сессии. +Дополнительно проверены фокус с клавиатуры, отсутствие горизонтального скролла, +ошибка обязательных данных, переключение входа/восстановления. Консольных JS ошибок +в сценарии не было. Внешние запросы блокировались; адреса API перенаправлялись на +loopback перехватом браузера. Изображение логотипа проверено unit tests, реальные +клиентские assets еще не предоставлены. Детектор ошибочно пометил Angular `[src]` +как отсутствующий `src`; привязка и условие непустого URL подтверждены по source. + +## Внедрение + +Сначала backend с добавочным контрактом, затем этот frontend. White label на +общем стенде по умолчанию выключен. Схема БД не меняется. Отдельная `pilot` +configuration использует `/backend`, свой WebSocket URL, host-only cookies с +префиксом `pilot_`, отдельный index/noindex и бренд экземпляра из `/instance/`. +Обычные dev/production configurations сохраняют свой контракт. + +11 ноября владелец опубликовал изолированный пилот `pilot.procollab.ru` с брендом +«Сознание права», HTTPS, внешним gate и новой БД без данных dev/prod. На пилот +установлен backend из API PR #861 и frontend `6a3d90f1`: исправлено приглашение +участника без ФИО по полной почте только в программе экземпляра. Окно показывает +реальную почту из scoped ответа API; строка команды использует ID. Отсутствующая +дата рождения не превращается в возраст, профиль не заполняется вымышленными +значениями. Обычный поиск по имени сохраняется. + +На опубликованном стенде Chromium проверил вход пяти ролей и короткую +desktop/mobile регистрацию двух новых QA-аккаунтов. После подтверждения почты +через обычный API по локальным письмам участники автоматически вступили в +программу, лидер вошел без онбординга и создал проект через UI. После исправления +он нашел участника по почте, отправил приглашение (201), участник принял его +с телефона (200). В команде видны оба аккаунта; чужой аккаунт получает 403 при +попытке принять приглашение. + +В пустой демопрограмме через существующие настройки организатора добавлены +явно тестовый кейс и поле файла. Лидер выбрал кейс, загрузил PDF через UI (201, +download побайтно совпал) и сдал проект через подтверждение (200). Organizer +видит сданную работу. После сдачи guard переводит редактор в просмотр; сервер +запрещает изменение команды/полей и повторную сдачу. Проверены mobile 390×844, +отсутствие горизонтального скролла и page errors. + +Полный mobile password recovery также пройден: запрос/установка через UI, +возврат к программе, отказ старого пароля и использованной ссылки, вход по новому +паролю. Исходный пароль тестового аккаунта восстановлен через обычный API. +Реальные письма, клиентские материалы/критерии/веса, нагрузка на 100 участников, +клиентский домен и приемка остаются следующими этапами. Квиз не переносится. + +## Исправление возврата ко входу, 12 ноября 2026 + +Обнаружен разрыв маршрутов: корень пилота открывает клиентскую регистрацию, +а выход ведет на общий `/auth/login`. После клиентского входа дополнительно +требуется нажать «Открыть программу». Исправление использует существующую +форму и ее состояния; макет, backend, роли, регистрация и схема БД не меняются. + +План: перенаправить общие маршруты входа и запроса восстановления пароля +на соответствующие состояния клиентской формы; после успешного входа +в экземпляре сразу открыть программу. Для обычной платформы сохранить +прежний переход от входа к заполнению анкеты программы. + +- WL13: выход, прямой `/auth/login` и вход из защищенной ссылки показывают + ту же клиентскую форму, что доступна из регистрации. +- WL14: успешный вход в экземпляре сразу открывает программу, включая + организатора и эксперта без регистрации в качестве участника. Новая + регистрация в программе при входе не создается. +- WL15: безопасный внутренний `returnUrl` сохраняется; внешний адрес + игнорируется. Запрос восстановления открывает существующее состояние + восстановления, а подтверждение почты сохраняет приоритет. +- WL16: при выключенном клиентском режиме маршруты общей авторизации + и вход на публичной форме программы работают по прежнему контракту. + +Проверка: route/component tests, pilot и production builds, браузерный +сценарий вход → программа → выход → повторный вход на пилоте. Публикация +только frontend пилота, без изменения dev/prod и их данных. + +Проверки исправления: 48 целевых Vitest tests (routes, public registration, +AuthRequiredGuard, безопасный returnUrl), TypeScript solution contract, +scoped ESLint/Prettier и обе сборки — pilot/production — прошли. Сохраняются +прежние предупреждения Angular/Sass/budgets/CommonJS вне затронутых файлов. + +Исходная ошибка воспроизведена на опубликованном пилоте: после выхода +открывается `/auth/login` с общим макетом, домен остается пилотным. +Новая compiled pilot сборка проверена в Chromium с подстановкой только +локальных статических файлов поверх HTTPS и настоящим API пилота. Десять +browser checks прошли: короткая регистрация, вход без лишнего шага, reload, +выход на клиентскую форму, повторный вход, восстановление на mobile 390, +старая ссылка регистрации, защищенная ссылка, вход организатора и отсутствие +JS ошибок. Разрешены только POST входа/выхода собственных QA-сессий; +регистрация, письма, данные программы и критерии не изменялись. + +Review WL13–WL16: PASS по tests/build/browser и source trace. Новый макет, +API и permissions не добавлялись. Полная новая регистрация/подтверждение +и установка нового frontend на сервер в этой проверке не выполнялись; +существующая приемка регистрации остается отдельным evidence. На стенде +до установки сохраняется frontend `971e52fb`. + +12 ноября владелец установил auth frontend `75f41a5d` исправленным отдельным +установщиком `2796b99`: phase active, dev HTTP 200, контейнеры/БД/nginx +не менялись. Backup: `backup/auth-4cf721bc7c02` внутри pilot v2. + +Независимая приемка опубликованного HTTPS: 8 HTTP и 11 Chromium checks PASS. +Index и три измененных JS совпадают по SHA-256 с проверенной сборкой. В браузере +использовались реальные опубликованные файлы и API, без подмены статики: +короткая форма, вход сразу в программу, reload, выход на ту же клиентскую +форму, повторный вход, mobile 390, восстановление, прежняя ссылка регистрации, +защищенная ссылка организатора и возврат к критериям. Ошибок страницы нет; +браузер отправил только 6 POST входа/выхода собственных QA-сессий. Письма, +регистрация новых аккаунтов и настройки программы в этой приемке не менялись. + +Тестовая команда и сданный проект сохранены; оба прежних PDF совпали побайтно. +Критерии, их revision и существующий режим оценки остались прежними. Dev +отвечает 200. Временный установочный ZIP удален API его QA-владельца после +проверки SHA-256; распакованный пакет и backup сохранены. Проверка после cleanup +подтвердила index, команду, заявку, PDF и dev. Полный новый signup/email proof +не повторялся; его прежняя отдельная приемка и текущие component tests применимы. diff --git a/docs/weighted-case-criteria.md b/docs/weighted-case-criteria.md new file mode 100644 index 000000000..f70c90b4c --- /dev/null +++ b/docs/weighted-case-criteria.md @@ -0,0 +1,50 @@ + + +# Процентные веса критериев кейса + +Согласовано 11 ноября 2026: сумма весов числовых критериев — 100%, итог кейса — +0–100. [Требования, формула, API и полный AC audit](https://github.com/PROCOLLAB-github/api/blob/codex/weighted-case-criteria-20261111/docs/weighted-case-criteria.md) +находятся в API repo. Квиз и общий рейтинг квиза с кейсом в эту задачу не входят. + +## Поведение + +В существующем разделе «Оценивание → Критерии» организатор выбирает «Веса +критериев, 0–100», задает проценты в редакторах и сохраняет весь набор. Поле веса +есть у числовых критериев; сумма и валидность шкал проверяются до сохранения и на +сервере. Изменения при 409 остаются в черновике; первая сохраненная оценка +фиксирует критерии и режим. Для прежних программ остается «Сумма исходных баллов». + +Эксперт видит вес и вводит исходные баллы. Расчет выполняет API: +`(балл − минимум) / (максимум − минимум) × вес`. Итог команды — среднее полных +оценок экспертов. В результатах видны максимум 100, объяснение и вклады критериев. +Общая форма работает для Project и ProgramSolution; смешивания их сущностей нет. + +`scoringMode`, `weightPercent`, `contributions` — добавочные поля. Старый ответ API +без них читается как legacy. C00 1.22.0 и generated solution DTO/fixtures обновлены; +поля запроса оценки и маршруты сохраняются. Сначала устанавливаются API и его +добавочные миграции, затем Angular. Автоматического включения весов нет. + +## Проверка + +- Checked by tests: 57 тестов, 7 файлов — criteria, Project/Solution results, + shared expert form, management adapter, solution contract и solution evaluation. + TypeScript solution contract check прошел. В API прошли 235 тестов на отдельном + PostgreSQL; схемы C00 и 307 сценариев проверены валидатором. +- Checked by build: `ng build social_platform --configuration production` прошел. + Остаются прежние предупреждения Sass, budgets и CommonJS. Scoped ESLint и + Prettier проверяют только измененные файлы; глобальные автоисправления не нужны. +- Checked in browser: production build + настоящий локальный Django, синтетические + аккаунты, все внешние сервисы заблокированы. Организатор сохранил 40/35/25; + эксперт через UI отправил 8/7/6, второй через API — 10/8/8. Итоги 71,5 и 88, + рейтинг 79,75 из 100. Проверены блокировка после первой оценки, формула, выгрузка, + desktop 1440 px, mobile 390 px без горизонтального скролла, Enter/Escape и + доступное имя числового поля. Ошибок страницы нет. XLSX после пересчета + LibreOffice совпал с API. +- Checked by source/design review: расширены существующие controls, cards, modal + и shared rating form; Mont и текущие tokens сохраняются, нового CSS/анимаций нет. + Loading/error/disabled/409/old API покрыты компонентными тестами. После + визуальной проверки сокращено только название режима, которое обрезалось на + телефоне. Невыполненных требований к этому расширению формы не осталось. + +Not checked: установка на pilot/dev/prod, реальная почта, нагрузка 100 участников +и приемка клиента. Скриншоты, логи и учетные данные QA остаются вне Git. diff --git a/projects/core/src/lib/services/tokens/token.service.spec.ts b/projects/core/src/lib/services/tokens/token.service.spec.ts index 22c5158c1..1c86151e4 100644 --- a/projects/core/src/lib/services/tokens/token.service.spec.ts +++ b/projects/core/src/lib/services/tokens/token.service.spec.ts @@ -7,6 +7,7 @@ import { HttpClientTestingModule } from "@angular/common/http/testing"; import { API_URL, PRODUCTION } from "../../providers"; import { ApiService } from "../api/api.service"; import Cookies from "js-cookie"; +import { environment } from "@environment"; describe("TokenService", () => { let service: TokenService; @@ -27,6 +28,86 @@ describe("TokenService", () => { it("should be created", () => { expect(service).toBeTruthy(); }); + it("пилот на поддомене имеет отдельную сессию и не меняет cookies платформы", () => { + const before = { + instanceMode: environment.instanceMode, + cookiePrefix: environment.cookiePrefix, + }; + const set = vi.spyOn(Cookies, "set").mockImplementation(() => undefined); + const get = vi.spyOn(Cookies, "get"); + const remove = vi.spyOn(Cookies, "remove").mockImplementation(() => undefined); + try { + environment.instanceMode = true; + environment.cookiePrefix = "pilot_"; + const pilot = new TokenService(TestBed.inject(ApiService), true, { + location: { hostname: "pilot.procollab.ru" }, + } as Document); + pilot.memTokens({ access: "pilot-access", refresh: "pilot-refresh" }); + expect(set).toHaveBeenCalledWith( + "pilot_accessToken", + "pilot-access", + expect.not.objectContaining({ domain: expect.anything() }), + ); + pilot.getTokens(); + expect(get).not.toHaveBeenCalledWith("accessToken"); + expect(get).not.toHaveBeenCalledWith("refreshToken"); + pilot.clearTokens(); + expect(remove).toHaveBeenCalledWith("pilot_accessToken", expect.any(Object)); + expect(remove).not.toHaveBeenCalledWith("accessToken", expect.anything()); + } finally { + Object.assign(environment, before); + set.mockRestore(); + get.mockRestore(); + remove.mockRestore(); + } + }); + it.each(["procollab.ru", "app.procollab.ru", "dev.procollab.ru"])( + "сохраняет общий production cookie domain на %s", + hostname => { + const production = new TokenService(TestBed.inject(ApiService), true, { + location: { hostname }, + } as Document); + expect(production.getCookieOptions()).toMatchObject({ + domain: ".procollab.ru", + secure: true, + sameSite: "None", + expires: expect.any(Date), + }); + }, + ); + it.each(["championship.example.test", "procollab.ru.example.test", "otherprocollab.ru"])( + "сохраняет и удаляет production cookies текущего host на %s", + hostname => { + const set = vi.spyOn(Cookies, "set").mockImplementation(() => undefined); + const remove = vi.spyOn(Cookies, "remove").mockImplementation(() => undefined); + try { + const production = new TokenService(TestBed.inject(ApiService), true, { + location: { hostname }, + } as Document); + const options = production.getCookieOptions(); + expect(options).not.toHaveProperty("domain"); + expect(options).toMatchObject({ secure: true, sameSite: "None" }); + production.memTokens({ access: "demo-access", refresh: "demo-refresh" }); + expect(set).toHaveBeenCalledWith( + "accessToken", + "demo-access", + expect.not.objectContaining({ domain: expect.anything() }), + ); + production.clearTokens(); + expect(remove).toHaveBeenCalledWith( + "accessToken", + expect.not.objectContaining({ domain: expect.anything() }), + ); + expect(remove).toHaveBeenCalledWith( + "refreshToken", + expect.not.objectContaining({ domain: expect.anything() }), + ); + } finally { + set.mockRestore(); + remove.mockRestore(); + } + }, + ); it("выход на dev очищает только dev-сессию, сохраняя production cookies", () => { const keys = ["devAccessToken", "devRefreshToken", "accessToken", "refreshToken"]; try { diff --git a/projects/core/src/lib/services/tokens/token.service.ts b/projects/core/src/lib/services/tokens/token.service.ts index 015bc767a..ab15f0e75 100644 --- a/projects/core/src/lib/services/tokens/token.service.ts +++ b/projects/core/src/lib/services/tokens/token.service.ts @@ -1,6 +1,7 @@ /** @format */ import { Inject, Injectable } from "@angular/core"; +import { DOCUMENT } from "@angular/common"; import { map, Observable } from "rxjs"; import { plainToInstance } from "class-transformer"; import Cookies from "js-cookie"; @@ -13,7 +14,7 @@ import { RefreshResponse, Tokens } from "@core/public-api"; * Сервис для управления JWT токенами аутентификации * * Основные функции: - * - Хранение access и refresh токенов в HTTP-only cookies + * - Хранение access и refresh токенов в cookies * - Автоматическое обновление токенов при истечении срока действия * - Управление настройками cookies в зависимости от окружения * - Очистка токенов при выходе из системы @@ -43,16 +44,17 @@ export class TokenService { } private get accessTokenKey(): string { - return this.isDevStage ? "devAccessToken" : "accessToken"; + return environment.cookiePrefix + (this.isDevStage ? "devAccessToken" : "accessToken"); } private get refreshTokenKey(): string { - return this.isDevStage ? "devRefreshToken" : "refreshToken"; + return environment.cookiePrefix + (this.isDevStage ? "devRefreshToken" : "refreshToken"); } constructor( private apiService: ApiService, @Inject(PRODUCTION) private production: boolean, + @Inject(DOCUMENT) private readonly document: Document, ) {} /** @@ -80,7 +82,7 @@ export class TokenService { * @returns Объект с настройками для js-cookie * * Production настройки: - * - domain: ".procollab.ru" - cookies доступны на всех поддоменах + * - domain: ".procollab.ru" на доменах платформы; текущий host у клиента * - expires: 30 дней - автоматическое истечение * * Development настройки: @@ -89,8 +91,10 @@ export class TokenService { */ getCookieOptions(): Parameters[2] { if (this.production) { + const hostname = this.document.location?.hostname; + const platformHost = hostname === "procollab.ru" || hostname?.endsWith(".procollab.ru"); return { - domain: ".procollab.ru", // Домен для production окружения + ...(platformHost && !environment.instanceMode ? { domain: ".procollab.ru" } : {}), expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 дней secure: true, sameSite: "None", diff --git a/projects/social_platform/src/app/api/instance/instance-info.service.spec.ts b/projects/social_platform/src/app/api/instance/instance-info.service.spec.ts new file mode 100644 index 000000000..bbe529375 --- /dev/null +++ b/projects/social_platform/src/app/api/instance/instance-info.service.spec.ts @@ -0,0 +1,63 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, throwError } from "rxjs"; +import { environment } from "@environment"; +import { InstanceRepositoryPort } from "@domain/instance/ports/instance.repository.port"; +import { InstanceInfoService } from "./instance-info.service"; + +describe("Конфигурация отдельного экземпляра", () => { + const data = { + enabled: true, + programId: 7, + brandName: "Сознание права", + logoUrl: "https://pilot.example.test/logo.png", + frontendUrl: "https://pilot.example.test", + }; + let previous: boolean; + const repository = { config: vi.fn() }; + beforeEach(() => { + previous = environment.instanceMode; + environment.instanceMode = true; + repository.config.mockReset(); + repository.config.mockReturnValue(of(data)); + TestBed.configureTestingModule({ + providers: [{ provide: InstanceRepositoryPort, useValue: repository }], + }); + }); + afterEach(() => { + environment.instanceMode = previous; + }); + it("обычная сборка не запрашивает конфигурацию", async () => { + environment.instanceMode = false; + const instance = TestBed.inject(InstanceInfoService); + await instance.initialize(); + expect(repository.config).not.toHaveBeenCalled(); + expect(instance.name()).toBe("PROCOLLAB"); + }); + it("пилот получает бренд и маршруты именно своей программы", async () => { + const instance = TestBed.inject(InstanceInfoService); + await instance.initialize(data.frontendUrl); + expect(instance.enabled()).toBe(true); + expect(instance.name()).toBe("Сознание права"); + expect(instance.programUrl()).toBe("/office/program/7"); + expect(instance.registrationUrl()).toBe("/program-registration/7"); + }); + it.each([ + { enabled: false }, + { ...data, programId: 0 }, + { ...data, frontendUrl: "https://other.example.test" }, + ])("ошибочная конфигурация не открывает общий кабинет", async value => { + repository.config.mockReturnValue(of(value)); + const instance = TestBed.inject(InstanceInfoService); + await instance.initialize(data.frontendUrl); + expect(instance.error()).toContain("Не удалось загрузить настройки пилота"); + expect(instance.enabled()).toBe(false); + }); + it("сбой API показывает ошибку, не использует основной API", async () => { + repository.config.mockReturnValue(throwError(() => new Error("unavailable"))); + const instance = TestBed.inject(InstanceInfoService); + await instance.initialize(); + expect(instance.error()).toBeTruthy(); + expect(repository.config).toHaveBeenCalledTimes(1); + }); +}); diff --git a/projects/social_platform/src/app/api/instance/instance-info.service.ts b/projects/social_platform/src/app/api/instance/instance-info.service.ts new file mode 100644 index 000000000..529b5f09c --- /dev/null +++ b/projects/social_platform/src/app/api/instance/instance-info.service.ts @@ -0,0 +1,47 @@ +/** @format */ +import { computed, inject, Injectable, signal } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { environment } from "@environment"; +import { InstanceConfig } from "@domain/instance/instance.model"; +import { InstanceRepositoryPort } from "@domain/instance/ports/instance.repository.port"; + +@Injectable({ providedIn: "root" }) +export class InstanceInfoService { + private readonly repository = inject(InstanceRepositoryPort, { optional: true }); + private readonly config = signal({ enabled: false }); + readonly enabled = computed(() => this.config().enabled); + readonly demoMode = computed(() => this.config().demoMode === true); + readonly error = signal(null); + readonly name = computed(() => this.config().brandName || "PROCOLLAB"); + readonly logo = computed(() => + this.enabled() ? this.config().logoUrl || "" : "/assets/images/shared/logo.svg", + ); + readonly logoBackground = computed(() => this.config().logoBackground ?? "transparent"); + readonly favicon = computed(() => this.config().faviconUrl || ""); + readonly programId = computed(() => this.config().programId ?? null); + readonly registrationUrl = computed(() => `/program-registration/${this.programId()}`); + readonly programUrl = computed(() => `/office/program/${this.programId()}`); + + async initialize(expectedOrigin?: string): Promise { + if (!environment.instanceMode) return; + try { + if (!this.repository) throw new Error("Instance repository missing"); + const data = await firstValueFrom(this.repository.config()); + if ( + !data.enabled || + !Number.isInteger(data.programId) || + (data.programId ?? 0) < 1 || + !data.brandName?.trim() || + (expectedOrigin && data.frontendUrl !== expectedOrigin) + ) { + throw new Error("Instance not configured"); + } + this.config.set(data); + this.error.set(null); + } catch { + this.error.set( + "Не удалось загрузить настройки пилота. Обновите страницу или обратитесь к организатору.", + ); + } + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts index e37c466e0..2db468491 100644 --- a/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts @@ -25,6 +25,12 @@ export class ProgramManagementUseCase { results(id: number) { return this.repository.results(id); } + overallResults(id: number, query: ResultsQuery) { + return this.repository.overallResults(id, query); + } + exportOverallResults(id: number, query: ResultsQuery) { + return this.repository.exportOverallResults(id, query); + } exportResults(id: number, query: ResultsQuery) { return this.repository.exportResults(id, query); } diff --git a/projects/social_platform/src/app/app.component.html b/projects/social_platform/src/app/app.component.html index ece9b1d56..9a66ede52 100644 --- a/projects/social_platform/src/app/app.component.html +++ b/projects/social_platform/src/app/app.component.html @@ -6,5 +6,13 @@ mode="query" > - - +@if (instance.error(); as error) { +
+

Пилот кейс-чемпионата

+

{{ error }}

+ +
+} @else { + + +} diff --git a/projects/social_platform/src/app/app.component.ts b/projects/social_platform/src/app/app.component.ts index 046290a4d..183bbdecb 100644 --- a/projects/social_platform/src/app/app.component.ts +++ b/projects/social_platform/src/app/app.component.ts @@ -20,6 +20,8 @@ import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; import { LoadingService } from "@api/shared/loading.service"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { AppRoutes } from "@api/paths/app-routes"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; +import { AlertDirective, ButtonDirective } from "@uilib"; /** * Корневой компонент приложения @@ -31,10 +33,18 @@ import { AppRoutes } from "@api/paths/app-routes"; selector: "app-root", templateUrl: "./app.component.html", styleUrls: ["./app.component.scss"], - imports: [MatProgressBarModule, RouterOutlet, AsyncPipe, CookieConsentComponent], + imports: [ + MatProgressBarModule, + RouterOutlet, + AsyncPipe, + CookieConsentComponent, + AlertDirective, + ButtonDirective, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements OnInit { + readonly instance = inject(InstanceInfoService); private readonly logger = inject(LoggerService); private readonly destroyRef = inject(DestroyRef); @@ -46,6 +56,7 @@ export class AppComponent implements OnInit { ) {} ngOnInit(): void { + if (this.instance.error()) return; const showLoaderEvents = this.router.events.pipe( filter(evt => evt instanceof ResolveStart), map(() => true), @@ -70,15 +81,23 @@ export class AppComponent implements OnInit { this.isLoading$ = this.loadingService.isLoading$; if (location.pathname === "/") { - if (this.tokenService.getTokens() === null) { - this.router - .navigateByUrl(AppRoutes.auth.login()) - .then(() => this.logger.debug("Route changed from AppComponent")); + if (this.instance.enabled()) { + void this.router.navigateByUrl( + this.tokenService.getTokens() + ? this.instance.programUrl() + : this.instance.registrationUrl(), + ); } else { - this.logger.debug("Route start changing from AppComponent"); - this.router - .navigateByUrl(AppRoutes.office.root()) - .then(() => this.logger.debug("Route changed From AppComponent")); + if (this.tokenService.getTokens() === null) { + this.router + .navigateByUrl(AppRoutes.auth.login()) + .then(() => this.logger.debug("Route changed from AppComponent")); + } else { + this.logger.debug("Route start changing from AppComponent"); + this.router + .navigateByUrl(AppRoutes.office.root()) + .then(() => this.logger.debug("Route changed From AppComponent")); + } } } @@ -113,4 +132,7 @@ export class AppComponent implements OnInit { } isLoading$?: Observable; + reload() { + location.reload(); + } } diff --git a/projects/social_platform/src/app/app.config.ts b/projects/social_platform/src/app/app.config.ts index e5743fc85..bfaefca72 100644 --- a/projects/social_platform/src/app/app.config.ts +++ b/projects/social_platform/src/app/app.config.ts @@ -53,6 +53,9 @@ import { VACANCY_PROVIDERS } from "./infrastructure/di/vacancy.providers"; import { COURSES_PROVIDERS } from "./infrastructure/di/courses/courses.providers"; import { CHAT_PROVIDERS } from "./infrastructure/di/chat.providers"; import { ThemeService } from "@uilib"; +import { InstanceRepositoryPort } from "@domain/instance/ports/instance.repository.port"; +import { InstanceHttpAdapter } from "@infrastructure/adapters/instance/instance-http.adapter"; +import { InstanceBrandService } from "@ui/services/instance-brand.service"; registerLocaleData(localeRu, "ru-RU"); @@ -65,6 +68,8 @@ export const APP_CONFIG: ApplicationConfig = { provideAppInitializer(() => { inject(ThemeService); }), + { provide: InstanceRepositoryPort, useClass: InstanceHttpAdapter }, + provideAppInitializer(() => inject(InstanceBrandService).initialize()), { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, diff --git a/projects/social_platform/src/app/domain/instance/instance.model.ts b/projects/social_platform/src/app/domain/instance/instance.model.ts new file mode 100644 index 000000000..cb801895e --- /dev/null +++ b/projects/social_platform/src/app/domain/instance/instance.model.ts @@ -0,0 +1,11 @@ +/** @format */ +export interface InstanceConfig { + enabled: boolean; + programId?: number; + brandName?: string; + logoUrl?: string; + faviconUrl?: string; + logoBackground?: "transparent" | "dark"; + frontendUrl?: string; + demoMode?: boolean; +} diff --git a/projects/social_platform/src/app/domain/instance/ports/instance.repository.port.ts b/projects/social_platform/src/app/domain/instance/ports/instance.repository.port.ts new file mode 100644 index 000000000..97887f0c0 --- /dev/null +++ b/projects/social_platform/src/app/domain/instance/ports/instance.repository.port.ts @@ -0,0 +1,7 @@ +/** @format */ +import { Observable } from "rxjs"; +import { InstanceConfig } from "../instance.model"; + +export abstract class InstanceRepositoryPort { + abstract config(): Observable; +} diff --git a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts index d6b27b355..19f083962 100644 --- a/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts @@ -2,6 +2,7 @@ import { Observable } from "rxjs"; import { ProgramResults, ResultsQuery } from "../program-results.model"; +import { ProgramOverallResults } from "../program-overall-results.model"; import { ManagementCriteria, ManagementCriteriaWrite } from "../program-criteria.model"; import { ManagementProjectForm, ManagementProjectFormWrite } from "../program-project-form.model"; import { @@ -31,6 +32,11 @@ export abstract class ProgramManagementRepositoryPort { body: ManagementProjectActionWrite & { previewToken: string }, ): Observable; abstract results(programId: number): Observable; + abstract overallResults( + programId: number, + query: ResultsQuery, + ): Observable; + abstract exportOverallResults(programId: number, query: ResultsQuery): Observable; abstract exportResults(programId: number, query: ResultsQuery): Observable; abstract criteria(programId: number): Observable; abstract saveCriteria( diff --git a/projects/social_platform/src/app/domain/program/program-criteria.model.ts b/projects/social_platform/src/app/domain/program/program-criteria.model.ts index bdb5c7684..905270c9a 100644 --- a/projects/social_platform/src/app/domain/program/program-criteria.model.ts +++ b/projects/social_platform/src/app/domain/program/program-criteria.model.ts @@ -1,6 +1,7 @@ /** @format */ export type CriterionType = "int" | "bool" | "str" | "float"; +export type EvaluationScoringMode = "legacy" | "weighted_percent"; export interface ManagementCriterion { id: number; name: string; @@ -8,6 +9,7 @@ export interface ManagementCriterion { type: CriterionType; minValue: number | null; maxValue: number | null; + weightPercent?: number | null; } export interface ManagementCriteria { updatedAt: string; @@ -15,9 +17,11 @@ export interface ManagementCriteria { locked: boolean; supportedTypes: CriterionType[]; criteria: ManagementCriterion[]; + scoringMode?: EvaluationScoringMode; } export interface ManagementCriteriaWrite { expectedUpdatedAt: string; expectedRevision: string; + scoringMode?: EvaluationScoringMode; criteria: (Omit & { id: number | null })[]; } diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index 6b0569776..8c6bc0394 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -9,6 +9,7 @@ export type ManagementSection = | "projects" | "experts" | "results" + | "overall-results" | "analytics" | "certificates" | "settings" diff --git a/projects/social_platform/src/app/domain/program/program-overall-results.model.ts b/projects/social_platform/src/app/domain/program/program-overall-results.model.ts new file mode 100644 index 000000000..6830a4885 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-overall-results.model.ts @@ -0,0 +1,48 @@ +/** @format */ + +import { EvaluationScoringMode } from "./program-criteria.model"; + +export type OverallResultStatus = + | "configuration_required" + | "pending_both" + | "pending_case" + | "pending_quiz" + | "complete"; + +export interface OverallResultProject { + /** ID участия проекта в программе; не ID проекта. */ + id: number; + projectId: number; + name: string; + caseName: string | null; + submitted: boolean; + caseScore: number | null; + caseComplete: boolean; + caseStatus: "not_configured" | "not_submitted" | "pending" | "complete"; + caseEvaluatorsCompleted: number; + caseEvaluatorsRequired: number; + quizScore: number | null; + quizComplete: boolean; + score: number | null; + ready: boolean; + status: OverallResultStatus; + place: number | null; +} + +export interface ProgramOverallResults { + programId: number; + programName: string; + generatedAt: string; + formula: "case_plus_quiz"; + caseMaximum: 100; + quizMaximum: 100; + maximum: 200; + caseScoringMode: EvaluationScoringMode; + caseScoringConfigured: boolean; + caseOptions: string[]; + totalCount: number; + totalReadyCount: number; + matchingCount: number; + readyCount: number; + projects: OverallResultProject[]; +} diff --git a/projects/social_platform/src/app/domain/program/program-registration.model.ts b/projects/social_platform/src/app/domain/program/program-registration.model.ts index 0ead2ad0d..d8621f9aa 100644 --- a/projects/social_platform/src/app/domain/program/program-registration.model.ts +++ b/projects/social_platform/src/app/domain/program/program-registration.model.ts @@ -39,6 +39,8 @@ export interface RegistrationPublic { enrolled: boolean; programUrl: string; registrationEnds: string; + accountMode?: "email_password"; + branding?: { name: string; logoUrl: string; logoBackground?: "transparent" | "dark" }; } export interface RegistrationImport { options: string[]; diff --git a/projects/social_platform/src/app/domain/program/program-results.model.ts b/projects/social_platform/src/app/domain/program/program-results.model.ts index 7ec528248..6f9a40566 100644 --- a/projects/social_platform/src/app/domain/program/program-results.model.ts +++ b/projects/social_platform/src/app/domain/program/program-results.model.ts @@ -1,5 +1,5 @@ /** @format */ -import { ManagementCriterion } from "./program-criteria.model"; +import { EvaluationScoringMode, ManagementCriterion } from "./program-criteria.model"; import { SolutionRef } from "@domain/solution/solution-contract.model"; export interface ResultExpert { @@ -23,6 +23,7 @@ export interface ResultProject { score: number | null; place: number | null; averages: Record; + contributions?: Record; experts: ResultExpert[]; } export interface ResultsGroup { @@ -38,6 +39,7 @@ export interface ProgramResults { generatedAt: string; requiredEvaluators: number; maximum: number | null; + scoringMode?: EvaluationScoringMode; hasNumericCriteria: boolean; caseOptions: string[]; criteria: ManagementCriterion[]; diff --git a/projects/social_platform/src/app/domain/project/project-rating-criterion.ts b/projects/social_platform/src/app/domain/project/project-rating-criterion.ts index d429dcd30..58bfdd9f3 100644 --- a/projects/social_platform/src/app/domain/project/project-rating-criterion.ts +++ b/projects/social_platform/src/app/domain/project/project-rating-criterion.ts @@ -10,6 +10,7 @@ export interface ProjectRatingCriterion { type: ProjectRatingCriterionType; minValue: number | null; maxValue: number | null; + weightPercent?: number | null; value: string | number; expertId: number; } diff --git a/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts b/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts index f0fb808d1..6374b17d9 100644 --- a/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts +++ b/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts @@ -12360,4 +12360,386 @@ export const solutionContractScenarios: SolutionContractScenario[] = [ meta: {}, }, }, + { + name: "solution_results_weighted_percent", + operationId: "getSolutionResults", + status: 200, + response: { + programId: 12, + programName: "Кейс-чемпионат PROCOLLAB", + generatedAt: "2026-10-24T20:00:00Z", + requiredEvaluators: 2, + maximum: 100, + hasNumericCriteria: true, + caseOptions: ["Цифровой сервис"], + groups: [ + { + caseName: "Цифровой сервис", + solutions: [ + { + application: { + kind: "solution", + programId: 12, + solutionId: 301, + name: "Сервис для студентов", + }, + solutionId: 301, + name: "Сервис для студентов", + caseName: "Цифровой сервис", + completed: 1, + required: 2, + evaluationComplete: false, + ready: false, + score: 54, + place: null, + averages: { + "21": 0, + "22": 4.5, + }, + experts: [ + { + userId: 91, + name: "Эксперт программы", + email: "expert@local.example", + avatar: null, + complete: true, + total: 54, + values: [ + { + criterionId: 21, + value: "0", + }, + { + criterionId: 22, + value: "4.5", + }, + { + criterionId: 23, + value: "False", + }, + { + criterionId: 24, + value: "", + }, + ], + }, + ], + contributions: { + "21": 0, + "22": 54, + }, + }, + ], + }, + ], + criteria: [ + { + id: 21, + name: "Практическая ценность", + description: "Польза аудитории", + type: "int", + minValue: 0, + maxValue: 10, + weightPercent: 40, + }, + { + id: 22, + name: "Проработанность", + description: null, + type: "float", + minValue: 0, + maxValue: 5, + weightPercent: 60, + }, + { + id: 23, + name: "Показан прототип", + description: null, + type: "bool", + minValue: null, + maxValue: null, + weightPercent: null, + }, + { + id: 24, + name: "Рекомендация", + description: null, + type: "str", + minValue: null, + maxValue: null, + weightPercent: null, + }, + ], + scoringMode: "weighted_percent", + }, + }, + { + name: "solution_rating_weighted_percent", + operationId: "rateSolution", + status: 200, + request: { + expectedCriteriaRevision: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + scores: [ + { + criterionId: 21, + value: "0", + }, + { + criterionId: 22, + value: "4.5", + }, + { + criterionId: 23, + value: "False", + }, + { + criterionId: 24, + value: + "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + }, + ], + }, + response: { + program: { + id: 12, + name: "Кейс-чемпионат PROCOLLAB", + }, + solution: { + id: 301, + programId: 12, + version: 10, + name: "Сервис для студентов", + captainId: 7, + createdAt: "2026-10-23T09:00:00Z", + updatedAt: "2026-10-23T10:05:00Z", + team: { + members: [ + { + user: { + id: 7, + firstName: "Иван", + lastName: "Петров", + avatarUrl: null, + }, + role: null, + specialization: null, + joinedAt: "2026-10-23T09:00:00Z", + isCaptain: true, + }, + { + user: { + id: 8, + firstName: "Анна", + lastName: "Смирнова", + avatarUrl: null, + }, + role: null, + specialization: null, + joinedAt: "2026-10-23T09:00:00Z", + isCaptain: false, + }, + ], + acceptedCount: 2, + pendingCount: 1, + reservedCount: 3, + minSize: 2, + maxSize: 4, + isFrozen: true, + }, + form: { + programId: 12, + revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + template: "case_championship", + isConfigured: true, + isSnapshot: true, + fields: [ + { + id: 1001, + type: "select", + purpose: "case", + label: "Кейс", + helpText: "", + required: true, + position: 0, + options: ["Цифровой сервис", "Операционная эффективность"], + validation: { + minLength: null, + maxLength: null, + minimum: null, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + { + id: 1002, + type: "textarea", + purpose: "other", + label: "Краткое описание", + helpText: "", + required: true, + position: 1, + options: [], + validation: { + minLength: null, + maxLength: 3000, + minimum: null, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + { + id: 1003, + type: "file", + purpose: "presentation", + label: "Презентация PDF", + helpText: "", + required: true, + position: 2, + options: [], + validation: { + minLength: null, + maxLength: null, + minimum: null, + maximum: null, + allowedExtensions: ["pdf"], + maxFileSizeBytes: 20971520, + }, + }, + { + id: 1004, + type: "number", + purpose: "other", + label: "Ожидаемый эффект", + helpText: "", + required: false, + position: 3, + options: [], + validation: { + minLength: null, + maxLength: null, + minimum: 0, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + { + id: 1005, + type: "checkbox", + purpose: "other", + label: "Приложены расчеты", + helpText: "", + required: false, + position: 4, + options: [], + validation: { + minLength: null, + maxLength: null, + minimum: null, + maximum: null, + allowedExtensions: [], + maxFileSizeBytes: null, + }, + }, + ], + }, + answers: [ + { + fieldId: 1001, + value: "Цифровой сервис", + }, + { + fieldId: 1002, + value: "Единый сервис поддержки студентов.", + }, + { + fieldId: 1004, + value: 0, + }, + { + fieldId: 1005, + value: false, + }, + { + fieldId: 1003, + value: { + url: "https://cdn.example.test/solutions/301/presentation.pdf", + }, + }, + ], + submission: { + required: true, + status: "submitted", + submissionOpen: true, + deadline: "2026-10-30T18:00:00Z", + submittedAt: "2026-10-23T10:05:00Z", + }, + permissions: { + canEdit: false, + canManageTeam: false, + canInvite: false, + canSubmit: false, + canLeave: false, + }, + readiness: { + isReady: true, + issues: [], + }, + }, + criteriaRevision: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + criteria: [ + { + id: 21, + name: "Практическая ценность", + description: "Польза аудитории", + type: "int", + minValue: 0, + maxValue: 10, + value: "0", + weightPercent: 40, + }, + { + id: 22, + name: "Проработанность", + description: null, + type: "float", + minValue: 0, + maxValue: 5, + value: "4.5", + weightPercent: 60, + }, + { + id: 23, + name: "Показан прототип", + description: null, + type: "bool", + minValue: null, + maxValue: null, + value: "False", + weightPercent: null, + }, + { + id: 24, + name: "Рекомендация", + description: null, + type: "str", + minValue: null, + maxValue: null, + value: + "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА", + weightPercent: null, + }, + ], + managedEvaluation: true, + canRate: true, + blockedReason: null, + isCompleted: true, + ratedCount: 1, + maxRates: 3, + requiredEvaluators: 2, + deadline: "2026-10-30T18:00:00Z", + }, + }, ]; diff --git a/projects/social_platform/src/app/domain/solution/solution-contract.model.ts b/projects/social_platform/src/app/domain/solution/solution-contract.model.ts index 4ec855694..2cbb482fd 100644 --- a/projects/social_platform/src/app/domain/solution/solution-contract.model.ts +++ b/projects/social_platform/src/app/domain/solution/solution-contract.model.ts @@ -1,6 +1,6 @@ /** @format */ -// Сгенерировано tools/generate-solution-contract.py из C00 1.21.0. +// Сгенерировано tools/generate-solution-contract.py из C00 1.22.0. // camelCase соответствует CamelcaseInterceptor; значения ID и revision не преобразуются. export type SolutionUser = { @@ -379,6 +379,7 @@ export type SolutionRatingCriterion = { minValue: number | null; maxValue: number | null; value: string | null; + weightPercent?: number | null; }; export type SolutionRatingValue = { @@ -537,6 +538,7 @@ export type SolutionResultCriterion = { type: "int" | "float" | "bool" | "str"; minValue: number | null; maxValue: number | null; + weightPercent?: number | null; }; export type SolutionResultExpert = { @@ -562,6 +564,7 @@ export type SolutionResult = { place: number | null; averages: Record; experts: Array; + contributions?: Record; }; export type SolutionResultGroup = { @@ -579,6 +582,7 @@ export type SolutionResults = { caseOptions: Array; groups: Array; criteria: Array; + scoringMode?: "legacy" | "weighted_percent"; }; export type SolutionCabinetApplication = { diff --git a/projects/social_platform/src/app/infrastructure/adapters/instance/instance-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/instance/instance-http.adapter.spec.ts new file mode 100644 index 000000000..cd85f4860 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/instance/instance-http.adapter.spec.ts @@ -0,0 +1,32 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { API_URL, CamelcaseInterceptor } from "@corelib"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { InstanceHttpAdapter } from "./instance-http.adapter"; + +it("пилот читает brand metadata со своего /backend и сохраняет casing", () => { + TestBed.configureTestingModule({ + providers: [ + InstanceHttpAdapter, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "/backend" }, + { provide: LoggerService, useValue: { warn: vi.fn() } }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + ], + }); + TestBed.inject(InstanceHttpAdapter) + .config() + .subscribe(data => { + expect(data.brandName).toBe("Сознание права"); + expect(data.programId).toBe(1); + expect(data.logoBackground).toBe("dark"); + }); + const http = TestBed.inject(HttpTestingController); + http + .expectOne("/backend/instance/") + .flush({ enabled: true, program_id: 1, brand_name: "Сознание права", logo_background: "dark" }); + http.verify(); +}); diff --git a/projects/social_platform/src/app/infrastructure/adapters/instance/instance-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/instance/instance-http.adapter.ts new file mode 100644 index 000000000..8389688ec --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/instance/instance-http.adapter.ts @@ -0,0 +1,13 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { InstanceConfig } from "@domain/instance/instance.model"; +import { InstanceRepositoryPort } from "@domain/instance/ports/instance.repository.port"; + +@Injectable() +export class InstanceHttpAdapter implements InstanceRepositoryPort { + private readonly api = inject(ApiService); + config() { + return this.api.get("/instance/"); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts index 246907e42..b74062d6d 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts @@ -11,6 +11,54 @@ import { API_URL } from "@core/lib/providers"; describe("Контракт HTTP панели организатора", () => { let adapter: ProgramManagementHttpAdapter, http: HttpTestingController; + it("общий рейтинг сохраняет ноль, null, ID проекта и глобальное место", () => { + const query = { + search: "Команда", + caseScope: "all", + order: "desc", + scope: "all", + status: "pending", + } as const; + adapter.overallResults(4, query).subscribe(data => { + expect(data.maximum).toBe(200); + expect(data.caseScoringConfigured).toBe(true); + expect(data.projects[0]).toMatchObject({ + id: 15, + projectId: 91, + caseScore: 0, + quizScore: 0, + score: 0, + place: 8, + ready: true, + }); + expect(data.projects[1]).toMatchObject({ + score: null, + place: null, + status: "pending_quiz", + caseEvaluatorsRequired: 2, + }); + }); + const response = http.expectOne(r => r.url.endsWith("/programs/4/management/overall-results/")); + expect(response.request.method).toBe("GET"); + expect(response.request.params.get("search")).toBe("Команда"); + expect(response.request.params.get("status")).toBe("pending"); + expect(response.request.params.get("scope")).toBe("current"); + response.flush({ + maximum: 200, + case_scoring_configured: true, + projects: [ + { id: 15, project_id: 91, case_score: 0, quiz_score: 0, score: 0, place: 8, ready: true }, + { score: null, place: null, status: "pending_quiz", case_evaluators_required: 2 }, + ], + }); + adapter.exportOverallResults(4, { ...query, scope: "current" }).subscribe(); + const file = http.expectOne(r => r.url.endsWith("/overall-results/export/")); + expect(file.request.params.get("search")).toBe("Команда"); + expect(file.request.params.get("status")).toBe("pending"); + expect(file.request.params.get("scope")).toBe("current"); + expect(file.request.responseType).toBe("blob"); + file.flush(new Blob(["xlsx"])); + }); it("возврат в черновик передает версию и получает новый статус через общий interceptor", () => { const updatedAt = "2026-10-30T10:00:00.123456Z"; adapter.returnToDraft(4, updatedAt).subscribe(program => { diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts index d2113f1cc..2455f2a71 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts @@ -4,6 +4,7 @@ import { inject, Injectable } from "@angular/core"; import { HttpParams } from "@angular/common/http"; import { ApiService } from "@corelib"; import { ProgramResults, ResultsQuery } from "@domain/program/program-results.model"; +import { ProgramOverallResults } from "@domain/program/program-overall-results.model"; import { ManagementCriteria, ManagementCriteriaWrite, @@ -48,7 +49,19 @@ export class ProgramManagementHttpAdapter implements ProgramManagementRepository results(id: number) { return this.api.get(`${this.root(id)}/results/`); } + overallResults(id: number, query: ResultsQuery) { + return this.api.get( + `${this.root(id)}/overall-results/`, + this.resultsParams({ ...query, scope: "current" }), + ); + } + exportOverallResults(id: number, query: ResultsQuery) { + return this.api.getFile(`${this.root(id)}/overall-results/export/`, this.resultsParams(query)); + } exportResults(id: number, query: ResultsQuery) { + return this.api.getFile(`${this.root(id)}/results/export/`, this.resultsParams(query)); + } + private resultsParams(query: ResultsQuery): HttpParams { let params = new HttpParams() .set("search", query.search) .set("case_scope", query.caseScope) @@ -56,7 +69,7 @@ export class ProgramManagementHttpAdapter implements ProgramManagementRepository .set("scope", query.scope); params = params.set("status", query.status ?? "all"); if (query.caseName) params = params.set("case_name", query.caseName); - return this.api.getFile(`${this.root(id)}/results/export/`, params); + return params; } criteria(id: number) { return this.api.get(`${this.root(id)}/criteria/`); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts index 2a29c3ede..3b9eca15d 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts @@ -23,6 +23,20 @@ describe("Контракт регистрации программы", () => { http = TestBed.inject(HttpTestingController); }); afterEach(() => http.verify()); + it("читает короткий режим и вложенный логотип из snake_case ответа API", () => { + adapter.publicForm(4).subscribe(result => { + expect(result.accountMode).toBe("email_password"); + expect(result.branding).toEqual({ + name: "Чемпионат клиента", + logoUrl: "https://client.example.test/logo.svg", + }); + }); + const request = http.expectOne("https://local.test/programs/4/registration/"); + request.flush({ + account_mode: "email_password", + branding: { name: "Чемпионат клиента", logo_url: "https://client.example.test/logo.svg" }, + }); + }); it("не меняет динамические идентификаторы ответов при snake/camel преобразовании", () => { const key = "f0123456789abcdef0123456789abcdef"; adapter diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts index 066634092..55d421cc9 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts @@ -29,6 +29,12 @@ export class ProgramManagementRepository implements ProgramManagementRepositoryP results(id: number) { return this.adapter.results(id); } + overallResults(id: number, query: ResultsQuery) { + return this.adapter.overallResults(id, query); + } + exportOverallResults(id: number, query: ResultsQuery) { + return this.adapter.exportOverallResults(id, query); + } exportResults(id: number, query: ResultsQuery) { return this.adapter.exportResults(id, query); } diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html index 9a697ed2d..3e9ef799e 100644 --- a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html @@ -1,13 +1,18 @@
- + @if (instance.logo()) { + + } @else { + {{ instance.name() }} + }
diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts index 2eaa004a9..7c27a6fa8 100644 --- a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts @@ -7,6 +7,7 @@ import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/cor import { IconComponent } from "@ui/primitives"; import { AuthEmailService } from "@api/auth/facades/auth-email.service"; import { CommonModule } from "@angular/common"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; /** Страница ожидания подтверждения email с возможностью повторной отправки. */ @Component({ @@ -18,6 +19,7 @@ import { CommonModule } from "@angular/common"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmailVerificationComponent implements OnInit { + readonly instance = inject(InstanceInfoService); private readonly authEmailService = inject(AuthEmailService); protected readonly counter = this.authEmailService.counter; 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 673560094..690199e1e 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 @@ -12,13 +12,18 @@ (keyup.enter)="onSubmit()" >
- + @if (instance.logo()) { + + } @else { + {{ instance.name() }} + } @if (loginForm.get("email"); as email) {
@@ -117,7 +122,12 @@ -
} -

Анкета участника

+ @if (data.fields.length || !shortAccount()) { +

Анкета участника

+ } = {}, + options: { instance?: boolean; query?: Record } = {}, +) { const fragment = new Subject(); sessionStorage.clear(); const api = { @@ -31,6 +38,7 @@ function setup(authenticated = false) { closed: false, enrolled: false, authenticated, + ...overrides, }), ), submit: vi.fn(() => of({ receipt: "opaque", email: "test@example.test" })), @@ -47,14 +55,22 @@ function setup(authenticated = false) { provide: ActivatedRoute, useValue: { paramMap: of(convertToParamMap({ id: "42" })), - queryParamMap: of(convertToParamMap({})), + queryParamMap: of(convertToParamMap(options.query ?? {})), fragment, - snapshot: { queryParamMap: convertToParamMap({}) }, + snapshot: { queryParamMap: convertToParamMap(options.query ?? {}) }, }, }, { provide: TokenService, useValue: tokens }, { provide: LoginUseCase, useValue: login }, { provide: ProfileInfoService, useValue: { invalidateProfile: vi.fn() } }, + { + provide: InstanceInfoService, + useValue: { + enabled: () => options.instance === true, + demoMode: () => false, + programId: () => 42, + }, + }, ], }); TestBed.overrideComponent(PublicRegistrationComponent, { @@ -65,6 +81,87 @@ function setup(authenticated = false) { return { fixture, component: fixture.componentInstance, api, tokens, login, fragment }; } describe("Публичная регистрация участника", () => { + const whiteLabel: Partial = { + accountMode: "email_password", + branding: { name: "Чемпионат клиента", logoUrl: "https://client.example.test/logo.svg" }, + }; + it("показывает бренд клиента и скрывает поля профиля в короткой форме", () => { + const { fixture, component } = setup(false, whiteLabel); + const page = fixture.nativeElement as HTMLElement; + expect(page.querySelector('[formControlName="firstName"]')).toBeNull(); + expect(page.querySelector('[formControlName="lastName"]')).toBeNull(); + expect(page.querySelector('[formControlName="birthday"]')).toBeNull(); + expect(page.querySelector("img")?.getAttribute("src")).toBe(whiteLabel.branding?.logoUrl); + expect(page.textContent).not.toContain("PROCOLLAB"); + expect(TestBed.inject(Title).getTitle()).toBe("Кейс-чемпионат | Чемпионат клиента"); + component.changeMode("login"); + fixture.detectChanges(); + expect(page.querySelector("h1")?.textContent).toContain("Вход в Чемпионат клиента"); + }); + it("отправляет почту, пароль и согласия без пустой даты рождения и ФИО", () => { + const { component, api } = setup(false, whiteLabel); + component.answers.controls[field.id].setValue("Москва"); + const credentials = { + email: "test@example.test", + password: "Secret-pass-2030", + repeatPassword: "Secret-pass-2030", + acceptPolicy: true, + acceptAge: true, + }; + component.account.patchValue(credentials); + component.submit(); + expect(api.submit).toHaveBeenCalledWith( + 42, + { version: 3, answers: { [field.id]: "Москва" }, ...credentials }, + true, + ); + expect(component.mode()).toBe("sent"); + }); + it("в короткой форме сохраняет требования к согласиям, паролю и ответам анкеты", () => { + const { component, api } = setup(false, whiteLabel); + component.account.patchValue({ email: "test@example.test", password: "short" }); + component.submit(); + expect(api.submit).not.toHaveBeenCalled(); + expect(component.account.controls.password.invalid).toBe(true); + expect(component.account.controls.acceptPolicy.invalid).toBe(true); + expect(component.account.controls.acceptAge.invalid).toBe(true); + expect(component.answers.invalid).toBe(true); + }); + it("возвращает полные требования при загрузке обычной программы", () => { + const { component, api, fixture } = setup(false, whiteLabel); + api.publicForm.mockReturnValue( + of({ + id: 42, + name: "Обычная программа", + version: 3, + fields: [], + closed: false, + enrolled: false, + authenticated: false, + }), + ); + component.load(); + fixture.detectChanges(); + expect(component.account.controls.firstName.invalid).toBe(true); + expect(component.account.controls.lastName.invalid).toBe(true); + expect(component.account.controls.birthday.invalid).toBe(true); + expect(fixture.nativeElement.querySelector('[formControlName="birthday"]')).toBeTruthy(); + expect(fixture.nativeElement.textContent).toContain("Аккаунт PROCOLLAB"); + }); + it("использует название клиента, когда логотип не настроен", () => { + const { fixture } = setup(false, { + ...whiteLabel, + branding: { name: "Чемпионат клиента", logoUrl: "" }, + }); + expect(fixture.nativeElement.querySelector("img")).toBeNull(); + expect(fixture.nativeElement.textContent).toContain("Чемпионат клиента"); + expect(fixture.nativeElement.textContent).not.toContain("PROCOLLAB"); + }); + it("не показывает пустой раздел анкеты в короткой форме", () => { + const { fixture } = setup(false, { ...whiteLabel, fields: [] }); + expect(fixture.nativeElement.textContent).not.toContain("Анкета участника"); + expect(fixture.nativeElement.querySelector('[formControlName="acceptPolicy"]')).toBeTruthy(); + }); it("открывает подтверждение по fragment без повторной загрузки той же страницы", () => { const { component, fragment, api } = setup(); component.mode.set("sent"); @@ -132,6 +229,76 @@ describe("Публичная регистрация участника", () => { component.changeMode("login"); expect(component.answers.getRawValue()[field.id]).toBe("Москва"); }); + it.each([false, true])( + "после входа в экземпляре сразу открывает программу, enrolled=%s", + enrolled => { + const { component, api, login, tokens } = setup(false, { enrolled }, { instance: true }); + const navigate = vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); + const credentials = { email: "test@example.test", password: "Secret-pass-2030" }; + const session = { access: "test-access", refresh: "test-refresh" }; + login.execute.mockReturnValue(of({ ok: true, value: { tokens: session } })); + component.credentials.setValue(credentials); + component.signIn(); + expect(login.execute).toHaveBeenCalledWith(credentials); + expect(tokens.memTokens).toHaveBeenCalledWith(session); + expect(navigate).toHaveBeenCalledWith("/office/program/42"); + expect(component.credentials.controls.password.value).toBe(""); + expect(api.submit).not.toHaveBeenCalled(); + expect(api.publicForm).toHaveBeenCalledTimes(1); + }, + ); + it.each([ + [ + "/office/program/42/management/settings?tab=criteria#weights", + "/office/program/42/management/settings?tab=criteria#weights", + ], + ["https://other.example.test/office", "/office/program/42"], + ["//other.example.test/office", "/office/program/42"], + ])("сохраняет только безопасный маршрут после входа: %s", (returnUrl, expected) => { + const { component, login } = setup(false, {}, { instance: true, query: { returnUrl } }); + const navigate = vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); + login.execute.mockReturnValue( + of({ ok: true, value: { tokens: { access: "test-access", refresh: "test-refresh" } } }), + ); + component.credentials.setValue({ email: "test@example.test", password: "Secret-pass-2030" }); + component.signIn(); + expect(navigate).toHaveBeenCalledWith(expected); + }); + it("сохраняет заполнение анкеты после входа в обычную программу", () => { + const { component, login, api } = setup(); + const navigate = vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); + login.execute.mockReturnValue( + of({ ok: true, value: { tokens: { access: "test-access", refresh: "test-refresh" } } }), + ); + component.changeMode("login"); + component.credentials.setValue({ email: "test@example.test", password: "Secret-pass-2030" }); + component.signIn(); + expect(navigate).not.toHaveBeenCalled(); + expect(component.mode()).toBe("form"); + expect(api.publicForm).toHaveBeenCalledTimes(2); + }); + it("открывает восстановление по ссылке", () => { + const { component } = setup(false, {}, { query: { mode: "reset" } }); + expect(component.mode()).toBe("reset"); + }); + it("открывает подтверждение, даже если ссылка содержит режим восстановления", () => { + const { component } = setup(false, {}, { query: { mode: "reset", confirmation: "proof" } }); + expect(component.mode()).toBe("confirm"); + expect(component.confirmToken()).toBe("proof"); + }); + it("оставляет ошибку входа на той же форме без перехода", () => { + const { component, login, tokens } = setup(false, {}, { instance: true }); + const navigate = vi.spyOn(TestBed.inject(Router), "navigateByUrl").mockResolvedValue(true); + login.execute.mockReturnValue(of({ ok: false, error: "invalid" })); + component.changeMode("login"); + component.credentials.setValue({ email: "test@example.test", password: "Secret-pass-2030" }); + component.signIn(); + expect(navigate).not.toHaveBeenCalled(); + expect(tokens.memTokens).not.toHaveBeenCalled(); + expect(component.mode()).toBe("login"); + expect(component.error()).toContain("Не удалось войти"); + expect(component.busy()).toBe(false); + }); it("не выдает локальную сессию на основе ошибки подтверждения", () => { const { component, api, tokens } = setup(); component.confirmToken.set("expired"); diff --git a/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.ts b/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.ts index e200c61cc..94cd8dd13 100644 --- a/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.ts @@ -2,13 +2,14 @@ import { ChangeDetectionStrategy, Component, + computed, DestroyRef, HostListener, inject, signal, } from "@angular/core"; import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { Meta } from "@angular/platform-browser"; +import { Meta, Title } from "@angular/platform-browser"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { debounceTime, finalize, Subscription } from "rxjs"; @@ -32,6 +33,8 @@ import { LoginUseCase } from "@api/auth/use-cases/login.use-case"; import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; import { evaluationError } from "@utils/evaluation-workspace"; import { userErrorMessage } from "@core/lib/models/error/user-error-message"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; +import { authReturnUrl } from "@core/lib/models/auth/auth-return-url"; @Component({ selector: "app-public-program-registration", imports: [ @@ -53,6 +56,7 @@ import { userErrorMessage } from "@core/lib/models/error/user-error-message"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PublicRegistrationComponent { + readonly instance = inject(InstanceInfoService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly api = inject(ProgramRegistrationUseCase); @@ -61,8 +65,12 @@ export class PublicRegistrationComponent { private readonly tokens = inject(TokenService); private readonly destroy = inject(DestroyRef); private readonly meta = inject(Meta); + private readonly title = inject(Title); + private readonly previousTitle = this.title.getTitle(); private readonly fb = inject(FormBuilder).nonNullable; readonly program = signal(null); + readonly shortAccount = computed(() => this.program()?.accountMode === "email_password"); + readonly brandName = computed(() => this.program()?.branding?.name ?? "PROCOLLAB"); readonly loading = signal(false); readonly busy = signal(false); readonly error = signal(null); @@ -101,6 +109,7 @@ export class PublicRegistrationComponent { this.answersSubscription?.unsubscribe(); if (previous !== undefined) this.meta.updateTag({ name: "referrer", content: previous }); else this.meta.removeTag('name="referrer"'); + this.title.setTitle(this.previousTitle); }); this.route.paramMap.pipe(takeUntilDestroyed()).subscribe(params => { this.answersSubscription?.unsubscribe(); @@ -126,9 +135,11 @@ export class PublicRegistrationComponent { ? "confirm" : this.route.snapshot.queryParamMap.get("mode") === "login" ? "login" - : this.receipt - ? "sent" - : "form", + : this.route.snapshot.queryParamMap.get("mode") === "reset" + ? "reset" + : this.receipt + ? "sent" + : "form", ); this.load(); }); @@ -188,6 +199,14 @@ export class PublicRegistrationComponent { } }); this.program.set(data); + this.title.setTitle( + data.branding ? `${data.name} | ${data.branding.name}` : this.previousTitle, + ); + for (const key of ["firstName", "lastName", "birthday"] as const) { + const control = this.account.controls[key]; + control.setValidators(this.shortAccount() ? [] : [Validators.required]); + control.updateValueAndValidity({ emitEvent: false }); + } this.authenticated.set(data.authenticated); }, error: e => { @@ -217,6 +236,8 @@ export class PublicRegistrationComponent { return; } const account = this.account.getRawValue(); + const { firstName, lastName, birthday, ...credentials } = account; + const signupAccount = this.shortAccount() ? credentials : account; if (!this.authenticated() && account.password !== account.repeatPassword) { this.error.set("Пароли не совпадают."); return; @@ -227,7 +248,7 @@ export class PublicRegistrationComponent { const payload = { version: data.version, answers: this.answers.getRawValue(), - ...(!this.authenticated() ? account : {}), + ...(!this.authenticated() ? signupAccount : {}), }; this.api .submit(this.id, payload, !this.authenticated()) @@ -315,6 +336,10 @@ export class PublicRegistrationComponent { this.profile.invalidateProfile(); this.authenticated.set(true); this.credentials.controls.password.reset(""); + if (this.instance.enabled() && this.instance.programId() === this.id) { + this.openProgram(); + return; + } this.mode.set("form"); this.load(); }, @@ -426,7 +451,11 @@ export class PublicRegistrationComponent { this.answers.markAsPristine(); this.account.markAsPristine(); this.profile.invalidateProfile(); - void this.router.navigateByUrl(`/office/program/${this.id}`); + const returnUrl = + this.instance.enabled() && this.instance.programId() === this.id + ? authReturnUrl(this.route.snapshot.queryParamMap.get("returnUrl")) + : null; + void this.router.navigateByUrl(returnUrl ?? `/office/program/${this.id}`); } @HostListener("window:beforeunload", ["$event"]) beforeUnload(event: BeforeUnloadEvent) { if (this.busy()) { diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html index a7cde1afe..5feb4bbc8 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html @@ -2,7 +2,7 @@
-

{{ collaborator().firstName }} {{ collaborator().lastName }}

+

{{ displayName() }}

{{ collaborator().role || "Роль не указана" }}

{{ isLeader() ? "Руководитель проекта" : "Участник проекта" }} @@ -17,9 +17,7 @@

{{ collaborator().firstName }} {{ collaborator().la appButton="danger" type="button" class="member-row__action member-row__action--danger" - [attr.aria-label]=" - 'Исключить участника: ' + collaborator().firstName + ' ' + collaborator().lastName - " + [attr.aria-label]="'Исключить участника: ' + displayName()" title="Исключить участника" [disabled]="removing() || frozen() || isLeader()" [attr.aria-busy]="removing()" diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts index 64b6dcc13..58071fea9 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts @@ -41,6 +41,18 @@ describe("CollaboratorCardComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + it("различает участников без ФИО по идентификатору", () => { + fixture.componentRef.setInput("collaborator", { + ...component.collaborator(), + firstName: "", + lastName: "", + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("h4").textContent).toBe("Участник №1"); + expect(fixture.nativeElement.querySelector("button").getAttribute("aria-label")).toContain( + "Участник №1", + ); + }); it("сохраняет подтверждение и не удаляет участника при ошибке, блокирует повтор", () => { const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); const execute = vi.mocked(TestBed.inject(RemoveProjectCollaboratorUseCase).execute); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts index e8b202f5a..6824330fc 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts @@ -3,6 +3,7 @@ import { teamOperationErrorMessage } from "@api/project/team-operation-error"; import { ChangeDetectionStrategy, Component, + computed, DestroyRef, inject, input, @@ -29,6 +30,13 @@ export class CollaboratorCardComponent { private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly collaborator = input.required(); + readonly displayName = computed( + () => + [this.collaborator().firstName, this.collaborator().lastName] + .filter(Boolean) + .join(" ") + .trim() || `Участник №${this.collaborator().userId}`, + ); readonly isCurrentUser = input(false); readonly frozen = input(false); readonly programLinkId = input(); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts index a7eab1adb..385982582 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts @@ -18,12 +18,14 @@ import { Invite } from "@domain/invite/invite.model"; import { Project } from "@domain/project/project.model"; import { RemoveProjectCollaboratorUseCase } from "@api/project/use-cases/remove-project-collaborator.use-case"; import { ProjectTeamStepComponent } from "./project-team-step.component"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; describe("Окно приглашения из редактора команды", () => { let fixture: ComponentFixture; let ui: ProjectTeamUIService; let request: Subject; const repo = { sendForUser: vi.fn() }; + const instance = { enabled: signal(false), programId: signal(27) }; const users = [1, 2, 3, 13].map(id => ({ id, firstName: "Иван", @@ -38,6 +40,8 @@ describe("Окно приглашения из редактора команды }, })); beforeEach(async () => { + instance.enabled.set(false); + instance.programId.set(27); request = new Subject(); repo.sendForUser.mockReset().mockReturnValue(request); await TestBed.configureTestingModule({ @@ -48,6 +52,7 @@ describe("Окно приглашения из редактора команды provideNgxMask(), ProjectTeamService, ProjectTeamUIService, + { provide: InstanceInfoService, useValue: instance }, { provide: ProfileInfoService, useValue: { profile: signal({ id: 1 }) } }, { provide: RemoveProjectCollaboratorUseCase, useValue: {} }, { provide: InviteRepositoryPort, useValue: repo }, @@ -95,6 +100,16 @@ describe("Окно приглашения из редактора команды await new Promise(resolve => setTimeout(resolve, 330)); await fixture.whenStable(); } + it("предлагает поиск по полной почте только внутри программы экземпляра", async () => { + instance.enabled.set(true); + await open(); + const input = dialog().querySelector('input[type="search"]') as HTMLInputElement; + expect(input.placeholder).toBe("Полная почта или имя участника…"); + expect(dialog().textContent).toContain("Введите полную почту или имя участника"); + instance.programId.set(28); + await fixture.whenStable(); + expect(input.placeholder).toBe("Поиск по имени или фамилии…"); + }); it("CTA открывает окно с поиском, URL отсутствует, отправка неактивна до выбора и роли", async () => { await open(); expect(ui.isInviteModalOpen()).toBe(true); diff --git a/projects/social_platform/src/app/ui/routes/auth/auth.routes.spec.ts b/projects/social_platform/src/app/ui/routes/auth/auth.routes.spec.ts new file mode 100644 index 000000000..c9930c0f4 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/auth/auth.routes.spec.ts @@ -0,0 +1,68 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + convertToParamMap, + provideRouter, + RouterStateSnapshot, + UrlTree, +} from "@angular/router"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; +import { AUTH_ROUTES } from "./auth.routes"; + +function guard(path: string, enabled: boolean, query: Record = {}) { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: InstanceInfoService, + useValue: { enabled: () => enabled, registrationUrl: () => "/program-registration/42" }, + }, + ], + }); + const route = AUTH_ROUTES[0].children!.find(route => route.path === path)!; + const activate = route.canActivate![0] as CanActivateFn; + return TestBed.runInInjectionContext(() => + activate( + { queryParamMap: convertToParamMap(query) } as ActivatedRouteSnapshot, + {} as RouterStateSnapshot, + ), + ) as UrlTree | boolean; +} + +describe("Авторизация клиентского экземпляра", () => { + it.each([ + ["login", "login"], + ["reset_password/send_email", "reset"], + ])("открывает клиентскую форму вместо общей страницы %s", (path, mode) => { + const result = guard(path, true) as UrlTree; + expect(result.root.children["primary"].segments.map(segment => segment.path)).toEqual([ + "program-registration", + "42", + ]); + expect(result.queryParams).toEqual({ mode }); + }); + it("сохраняет безопасную ссылку на защищенную страницу", () => { + const returnUrl = "/office/program/42/management/settings?tab=criteria#weights"; + expect((guard("login", true, { returnUrl }) as UrlTree).queryParams).toEqual({ + mode: "login", + returnUrl, + }); + }); + it.each(["https://other.example.test/", "//other.example.test", "/auth/login"])( + "не переносит недопустимый returnUrl: %s", + returnUrl => { + expect((guard("login", true, { returnUrl }) as UrlTree).queryParams).toEqual({ + mode: "login", + }); + }, + ); + it("регистрацию направляет на клиентскую форму", () => { + expect((guard("register", true) as UrlTree).toString()).toBe("/program-registration/42"); + }); + it.each(["login", "register", "reset_password/send_email"])( + "сохраняет общую страницу %s при выключенном клиентском режиме", + path => expect(guard(path, false)).toBe(true), + ); +}); diff --git a/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts b/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts index 19328864c..17b53e3d3 100644 --- a/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts +++ b/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts @@ -1,6 +1,9 @@ /** @format */ -import { Routes } from "@angular/router"; +import { CanActivateFn, Router, Routes } from "@angular/router"; +import { inject } from "@angular/core"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; +import { authReturnUrl } from "@core/lib/models/auth/auth-return-url"; import { AuthComponent } from "../../pages/auth/auth.component"; import { ResetPasswordComponent } from "@ui/pages/auth/reset-password/reset-password.component"; import { SetPasswordComponent } from "@ui/pages/auth/set-password/set-password.component"; @@ -10,6 +13,18 @@ import { RegisterComponent } from "@ui/pages/auth/register/register.component"; import { EmailVerificationComponent } from "@ui/pages/auth/email-verification/email-verification.component"; import { ConfirmEmailComponent } from "@ui/pages/auth/confirm-email/confirm-email.component"; +/** Общие ссылки входа в экземпляре открывают существующую клиентскую форму. */ +function instanceAuthForm(mode: "login" | "reset"): CanActivateFn { + return route => { + const instance = inject(InstanceInfoService); + if (!instance.enabled()) return true; + const returnUrl = authReturnUrl(route.queryParamMap.get("returnUrl")); + return inject(Router).createUrlTree([instance.registrationUrl()], { + queryParams: { mode, ...(returnUrl ? { returnUrl } : {}) }, + }); + }; +} + /** Конфигурация маршрутов для модуля аутентификации. */ export const AUTH_ROUTES: Routes = [ { @@ -24,10 +39,17 @@ export const AUTH_ROUTES: Routes = [ { path: "login", component: LoginComponent, + canActivate: [instanceAuthForm("login")], }, { path: "register", component: RegisterComponent, + canActivate: [ + () => { + const instance = inject(InstanceInfoService); + return instance.enabled() ? inject(Router).parseUrl(instance.registrationUrl()) : true; + }, + ], }, { path: "verification/email", @@ -36,6 +58,7 @@ export const AUTH_ROUTES: Routes = [ { path: "reset_password/send_email", component: ResetPasswordComponent, + canActivate: [instanceAuthForm("reset")], }, { path: "reset_password", diff --git a/projects/social_platform/src/app/ui/services/instance-brand.service.ts b/projects/social_platform/src/app/ui/services/instance-brand.service.ts new file mode 100644 index 000000000..cb6936e4e --- /dev/null +++ b/projects/social_platform/src/app/ui/services/instance-brand.service.ts @@ -0,0 +1,34 @@ +/** @format */ +import { DOCUMENT } from "@angular/common"; +import { inject, Injectable } from "@angular/core"; +import { Meta, Title } from "@angular/platform-browser"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; + +@Injectable({ providedIn: "root" }) +export class InstanceBrandService { + private readonly instance = inject(InstanceInfoService); + private readonly title = inject(Title); + private readonly meta = inject(Meta); + private readonly document = inject(DOCUMENT); + + async initialize(): Promise { + await this.instance.initialize(this.document.location.origin); + if (this.instance.error()) { + this.title.setTitle("Пилот кейс-чемпионата"); + return; + } + if (!this.instance.enabled()) return; + this.title.setTitle(this.instance.name()); + this.meta.updateTag({ name: "robots", content: "noindex, nofollow" }); + if (this.instance.favicon()) { + let icon = this.document.querySelector('link[rel="icon"]'); + if (!icon) { + icon = this.document.createElement("link"); + icon.rel = "icon"; + this.document.head.appendChild(icon); + } + icon.href = this.instance.favicon(); + icon.removeAttribute("type"); + } + } +} diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/invite-user-summary.component.spec.ts b/projects/social_platform/src/app/ui/widgets/project-invite/invite-user-summary.component.spec.ts new file mode 100644 index 000000000..d30a3d1f3 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-invite/invite-user-summary.component.spec.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { InviteUserSummaryComponent } from "./invite-user-summary.component"; + +describe("Участник без обязательных персональных полей", () => { + const render = (changes: Record = {}) => { + TestBed.configureTestingModule({ imports: [InviteUserSummaryComponent] }); + const fixture = TestBed.createComponent(InviteUserSummaryComponent); + const user = { + id: 71, + firstName: "", + lastName: "", + email: "member@example.test", + personal: { birthday: null, avatar: "", speciality: "" }, + relations: { skills: [] }, + ...changes, + }; + fixture.componentRef.setInput("user", user); + fixture.detectChanges(); + return { fixture, user, text: fixture.nativeElement.textContent as string }; + }; + + it("показывает возвращенную почту вместо пустого имени и сохраняет профиль", () => { + const { fixture, user } = render(); + expect(fixture.nativeElement.querySelector("strong").textContent).toBe("member@example.test"); + expect(user.firstName).toBe(""); + expect(user.lastName).toBe(""); + expect(user.personal.birthday).toBeNull(); + }); + + it("не считает возраст от 1970 года для отсутствующей даты рождения", () => { + const { fixture } = render(); + expect(fixture.componentInstance.age()).toBe(""); + expect(fixture.nativeElement.querySelector(".invite-meta").textContent.trim()).toBe(""); + }); + + it("сохраняет обычное имя и дает понятный идентификатор без почты", () => { + const { fixture } = render({ firstName: "Анна", lastName: "Иванова" }); + expect(fixture.nativeElement.querySelector("strong").textContent).toBe("Анна Иванова"); + fixture.componentRef.setInput("user", { + id: 72, + firstName: "", + lastName: "", + personal: { avatar: "", birthday: null }, + relations: { skills: [] }, + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("strong").textContent).toBe("Участник №72"); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/invite-user-summary.component.ts b/projects/social_platform/src/app/ui/widgets/project-invite/invite-user-summary.component.ts index 77c4cfefb..d578199d8 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/invite-user-summary.component.ts +++ b/projects/social_platform/src/app/ui/widgets/project-invite/invite-user-summary.component.ts @@ -13,7 +13,7 @@ import { User } from "@domain/auth/user.model"; } - {{ user().firstName }} {{ user().lastName }} + {{ displayName() }} {{ age() }}{{ age() && user().personal.speciality ? " · " : "" }}{{ user().personal.speciality }}(); readonly skills = computed(() => this.user().relations?.skills ?? []); + readonly displayName = computed( + () => + [this.user().firstName, this.user().lastName].filter(Boolean).join(" ").trim() || + this.user().email || + `Участник №${this.user().id}`, + ); readonly age = computed(() => { - const birthday = new Date(this.user().personal?.birthday); + const value = this.user().personal?.birthday; + if (!value) return ""; + const birthday = new Date(value); if (Number.isNaN(birthday.getTime())) return ""; const now = new Date(); const beforeBirthday = diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.html b/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.html index 93a2a28ad..785ff8bf5 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.html +++ b/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.html @@ -25,8 +25,12 @@ [id]="searchId" data-invite-autofocus type="search" - aria-label="Поиск по имени или фамилии" - placeholder="Поиск по имени или фамилии…" + [attr.aria-label]=" + emailSearch() ? 'Поиск по почте, имени или фамилии' : 'Поиск по имени или фамилии' + " + [placeholder]=" + emailSearch() ? 'Полная почта или имя участника…' : 'Поиск по имени или фамилии…' + " [formControl]="query()" [readOnly]="busy()" /> @@ -38,6 +42,7 @@ [selected]="selected()" [unavailableReason]="unavailableReason()" [busy]="busy()" + [emailSearch]="emailSearch()" (chosen)="chosen.emit($event)" (cleared)="clear()" /> diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.ts b/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.ts index 8fb81a839..18dcf7ece 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.ts +++ b/projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.ts @@ -36,6 +36,7 @@ export class MemberInviteFormComponent { readonly query = input.required>(); readonly role = input.required>(); readonly state = input.required(); + readonly emailSearch = input(false); readonly selected = input(null); readonly unavailableReason = input.required<(id: number) => string | null>(); readonly busy = input(false); diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/participant-picker.component.ts b/projects/social_platform/src/app/ui/widgets/project-invite/participant-picker.component.ts index e03dbf89d..dbe66d60d 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/participant-picker.component.ts +++ b/projects/social_platform/src/app/ui/widgets/project-invite/participant-picker.component.ts @@ -28,7 +28,13 @@ import { InviteUserSummaryComponent } from "./invite-user-summary.component"; } @else { @switch (state().status) { @case ("initial") { -

Введите имя или фамилию участника

+

+ {{ + emailSearch() + ? "Введите полную почту или имя участника" + : "Введите имя или фамилию участника" + }} +

} @case ("loading") {

@@ -42,7 +48,13 @@ import { InviteUserSummaryComponent } from "./invite-user-summary.component"; } @case ("success") { @if (!state().users.length) { -

Участники не найдены. Попробуйте другое имя.

+

+ {{ + emailSearch() + ? "Участники не найдены. Проверьте имя или полную почту и участие в программе." + : "Участники не найдены. Попробуйте другое имя." + }} +

}
@for (user of state().users; track user.id) { @@ -101,6 +113,7 @@ export class ParticipantPickerComponent { readonly collaborators = input([]); readonly invites = input([]); readonly busy = input(false); + readonly emailSearch = input(false); readonly unavailableReason = input<((id: number) => string | null) | null>(null); readonly chosen = output(); readonly cleared = output(); diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html index 71db30e82..c24dd8483 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html +++ b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html @@ -3,6 +3,7 @@ [query]="query" [role]="ui.inviteForm.controls.role" [state]="search.state()" + [emailSearch]="emailSearch()" [selected]="ui.selectedRecipient()" [unavailableReason]="candidateReason" [busy]="isLoading(ui.inviteFormIsSubmitting())" diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts index ae8aabef7..ac9f41821 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts +++ b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts @@ -4,6 +4,7 @@ import { ChangeDetectionStrategy, Component, + computed, DestroyRef, effect, inject, @@ -20,6 +21,7 @@ import { InviteParticipantSearchFacade } from "@api/invite/facades/invite-partic import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; import { ProjectTeamService } from "@api/project/facades/edit/project-team.service"; import { MemberInviteFormComponent } from "./member-invite-form.component"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; @Component({ selector: "app-project-member-invite-modal", @@ -39,6 +41,10 @@ export class ProjectMemberInviteModalComponent { readonly search = inject(InviteParticipantSearchFacade); readonly query = new FormControl("", { nonNullable: true }); readonly isLoading = isLoading; + private readonly instance = inject(InstanceInfoService); + readonly emailSearch = computed( + () => this.instance.enabled() && inviteProgramId(this.project()) === this.instance.programId(), + ); readonly candidateReason = (id: number) => inviteCandidateReason(id, this.project()?.leader, this.ui.collaborators(), this.ui.invites()); diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/solution-member-invite-modal.component.ts b/projects/social_platform/src/app/ui/widgets/project-invite/solution-member-invite-modal.component.ts index bb691f169..e489fcb3e 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/solution-member-invite-modal.component.ts +++ b/projects/social_platform/src/app/ui/widgets/project-invite/solution-member-invite-modal.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, + computed, DestroyRef, effect, inject, @@ -24,6 +25,7 @@ import { createProjectInviteForm } from "@api/invite/project-invite-form"; import { InviteParticipantSearchFacade } from "@api/invite/facades/invite-participant-search.facade"; import { CreateSolutionInvitationUseCase } from "@api/solution/use-cases/solution-team.use-cases"; import { MemberInviteFormComponent } from "./member-invite-form.component"; +import { InstanceInfoService } from "@api/instance/instance-info.service"; @Component({ selector: "app-solution-member-invite-modal", @@ -33,6 +35,7 @@ import { MemberInviteFormComponent } from "./member-invite-form.component"; [query]="query" [role]="form.controls.role" [state]="search.state()" + [emailSearch]="emailSearch()" [selected]="selected()" [unavailableReason]="reason" [busy]="busy()" @@ -56,6 +59,10 @@ export class SolutionMemberInviteModalComponent { readonly created = output(); readonly refreshRequired = output(); readonly search = inject(InviteParticipantSearchFacade); + private readonly instance = inject(InstanceInfoService); + readonly emailSearch = computed( + () => this.instance.enabled() && this.solution().programId === this.instance.programId(), + ); readonly form = createProjectInviteForm(); readonly query = new FormControl("", { nonNullable: true }); readonly selected = signal(null); diff --git a/projects/social_platform/src/environments/environment.pilot.ts b/projects/social_platform/src/environments/environment.pilot.ts new file mode 100644 index 000000000..ed8b742d8 --- /dev/null +++ b/projects/social_platform/src/environments/environment.pilot.ts @@ -0,0 +1,18 @@ +/** @format */ +export const environment = { + production: true, + buildProfile: "white-label-pilot-v1", + instanceMode: true, + cookiePrefix: "pilot_", + apiUrl: "/backend", + skillsApiUrl: "/backend", + websocketUrl: `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws`, + websocketReconnectionInterval: 5000, + websocketReconnectionMaxAttempts: 5, + sentryDns: "", + analyticsHost: "", + yandexMetrikaId: 0, + mailRuCounterId: "", + mailRuRegisterId: "", + registerPath: "/auth/register", +}; diff --git a/projects/social_platform/src/environments/environment.prod.ts b/projects/social_platform/src/environments/environment.prod.ts index 747ef27f9..6c74a9c31 100644 --- a/projects/social_platform/src/environments/environment.prod.ts +++ b/projects/social_platform/src/environments/environment.prod.ts @@ -1,6 +1,8 @@ /** @format */ export const environment = { + instanceMode: false, + cookiePrefix: "", production: true, sentryDns: "https://fc61f416df6044bab8c7e1afd55f4355@o1186023.ingest.sentry.io/6577563", apiUrl: "https://api.procollab.ru", diff --git a/projects/social_platform/src/environments/environment.ts b/projects/social_platform/src/environments/environment.ts index 32e5cf59b..39b088ade 100644 --- a/projects/social_platform/src/environments/environment.ts +++ b/projects/social_platform/src/environments/environment.ts @@ -5,6 +5,8 @@ // The list of file replacements can be found in `angular.json`. export const environment = { + instanceMode: false, + cookiePrefix: "", production: false, sentryDns: "https://fc61f416df6044bab8c7e1afd55f4355@o1186023.ingest.sentry.io/6577563", apiUrl: "https://dev.procollab.ru", // TODO: change it before merge diff --git a/projects/social_platform/src/index.pilot.html b/projects/social_platform/src/index.pilot.html new file mode 100644 index 000000000..1acb4eb47 --- /dev/null +++ b/projects/social_platform/src/index.pilot.html @@ -0,0 +1,17 @@ + + + + + + + Пилот кейс-чемпионата + + + + + + + + + + diff --git a/projects/social_platform/src/styles.scss b/projects/social_platform/src/styles.scss index 08e40ac74..0760ed653 100644 --- a/projects/social_platform/src/styles.scss +++ b/projects/social_platform/src/styles.scss @@ -8,6 +8,7 @@ // BASE @import "styles/colors.scss"; +@import "styles/instance-brand.scss"; @import "styles/global.scss"; @import "styles/responsive.scss"; @import "styles/rounded.scss"; diff --git a/projects/social_platform/src/styles/instance-brand.scss b/projects/social_platform/src/styles/instance-brand.scss new file mode 100644 index 000000000..2640b6d0a --- /dev/null +++ b/projects/social_platform/src/styles/instance-brand.scss @@ -0,0 +1,14 @@ +/** @format */ +.instance-logo--dark { + padding: 6px; + background: var(--ui-dark-ink); + border-radius: 4px; +} + +.instance-error { + display: grid; + gap: 20px; + max-width: 600px; + padding: 40px 20px; + margin: 0 auto; +} diff --git a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html index 23c9fc571..31aa677e8 100644 --- a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html +++ b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html @@ -1,8 +1,22 @@