Skip to content
Merged
76 changes: 76 additions & 0 deletions docs/prod-program-analytics-project-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<!-- @format -->

# PROD: аналитика программы и редактор проекта

Выборочный перенос проверенных DEV PR #348, #349, #350, #351 на PROD master
`72b50306cbd7c7fc33e2efad66052c43759b33db`. Слияния dev → master нет.

## Область изменений

- Ролевой виджет программы: организатор → эксперт → участник. Backend
`GET /programs/{id}/analytics-widget/` определяет права и отдаёт одну ветвь
метрик. Ошибки сохраняются как ошибки, не становятся нулевыми счётчиками.
- Новости программы загружаются и показываются организатору без member-роли.
Экспертная роль сама по себе не открывает новости.
- Очистка файлов использует инициализированный `Project.id`, а не корневой
ActivatedRoute. Невалидные ID и старые повреждённые записи offline queue
отфильтровываются до запроса. Начальное пустое поле не вызывает PUT.
- Явное удаление presentation сохраняет пустую строку. DELETE 404 идемпотентен;
network/4xx/5xx сохраняют control и показывают ошибку. Перед публикацией
очистка подтверждается отдельным сохранением черновика.
- Project-specific `POST /projects/{id}/reset-cover/` и canonical
`isDefaultCover` исключают DELETE системной обложки. Поле остаётся required.
- Стандартная/пустая обложка показывает «Стандартная обложка» и «Загрузить файл»,
пользовательская — «Заменить файл». Верхней иконки сброса нет.
Размеры и соседнее расположение элементов сохранены.

## Совместимость PROD

Существующие `currentProjectApplication` и URL основной legacy-аналитики
`project-analytics` сохранены. Виджет не использует это leader-only поле
для поиска проекта члена команды; его `participantProject` приходит отдельно.

PROD hotfix уведомлений, вакансий, регионов и другие отличия master остаются.
Shared app-modal и app-soon-card, зависимости, CI/workflows не изменяются.
React и его доменные контракты не затрагиваются.

Backend переносится первым: #742 (read-only виджет и is_user_expert) и #743
(reset-cover и is_default_cover). Миграций задачи нет. Старый frontend
совместим с новыми additive endpoints.

## Источники переноса

| DEV PR | Merge commit |
| ------ | ------------------------------------------ |
| #348 | `8f59c8ed7bbad1ceb1deba669c65b66a92040231` |
| #349 | `c0bbdd7a97f2575864e3d4d8c7d88e927b61bd47` |
| #350 | `2324f52345fe608991e888a514aefc491e578bcb` |
| #351 | `c66d05db59b6dbae160c0f3fc8853a9fa6644377` |

Большие DEV-only screenshots/diagnostics не включены в PROD diff. Они доступны
в исходных PR. Существующая документация master не удалялась.

## Проверки

Локальный прогон 18.09.2026, Node 20.20.2, отдельная ветка от PROD master:

- `npm ci` — успешно, lockfile не менялся.
- Targeted Vitest (17 файлов, `--pool=forks --maxWorkers=4 --minWorkers=1`) —
216/216, включая виджет, manager news, autosave, форму, edit info, UploadFile,
reset-cover и zero state.
- `npm run format:check`, `npm run lint:ts` (0 ошибок, 6 предупреждений),
scoped Stylelint, `npm run build:prod`, `git diff --check` — успешно.
- `npm run test:ci -- --pool=forks --maxWorkers=4 --minWorkers=1` —
**exit 1**: 377 файлов и 1728 тестов прошли, но возникла необработанная ошибка
`ReferenceError: window is not defined` из таймера `ngx-autosize` после teardown
`profile-mid-side.component.spec.ts`. Этот прогон не считается успешным.

Стандартный локальный pool ранее завершился на NG0401 до выполнения targeted
тестов. Forks меняет только способ изоляции процессов; тесты не исключались,
Vitest config и setup не изменялись. Ошибка полного прогона не подавлена.

PROD-релиз остановлен до snapshot/merge/deploy по условию задачи. Для продолжения
нужен успешный новый полный прогон; старые результаты DEV не заменяют этот gate.

Без отдельного production test account/project нельзя выполнять
авторизованную изменяющую данные приёмку на реальных проектах.
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ export class ProgramDetailMainService {
this.programDetailMainUIInfoService.applyFormatingProgramData(program);
}),
concatMap(program => {
// Новости программы доступны только участникам программы.
if (program.isUserMember) {
// Видимость списка и загрузка совпадают; эксперт сам по себе доступ не получает.
if (program.isUserMember || program.isUserManager) {
return this.fetchNews(0, this.fetchLimit());
} else {
return of(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/** @format */

import { TestBed } from "@angular/core/testing";
import { signal } from "@angular/core";
import { ActivatedRoute, convertToParamMap } from "@angular/router";
import { BehaviorSubject, Subject } from "rxjs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AuthInfoService } from "@api/auth/facades/auth-info.service";
import { GetProgramRoleWidgetUseCase } from "../../use-cases/get-program-role-widget.use-case";
import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service";
import { ProgramRoleWidgetService } from "./program-role-widget.service";
import { Program } from "@domain/program/program.model";
import { ProgramRoleWidget, ProgramWidgetError } from "@domain/program/program-role-widget.model";
import { fail, ok, Result } from "@domain/shared/result.type";
import { EventBus } from "@domain/shared/event-bus";

describe("ProgramRoleWidgetService", () => {
let service: ProgramRoleWidgetService;
const profile = signal<{ id: number } | null>({ id: 7 });
const program = signal(Object.assign(Program.default(), { id: 12, isUserMember: true }));
let params: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let responses: Subject<Result<ProgramRoleWidget, ProgramWidgetError>>[];
const result = (id = 12): ProgramRoleWidget => ({
programId: id,
isCompetitive: true,
role: "participant",
participant: {
participantProject: null,
caseProvided: false,
caseName: null,
stage: "none",
submissionOpen: true,
},
});
beforeEach(() => {
profile.set({ id: 7 });
program.set(Object.assign(Program.default(), { id: 12, isUserMember: true }));
params = new BehaviorSubject(convertToParamMap({ programId: 12 }));
responses = [];
TestBed.configureTestingModule({
providers: [
ProgramRoleWidgetService,
{ provide: AuthInfoService, useValue: { profile } },
{ provide: ProgramDetailMainUIInfoService, useValue: { program } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: params.value }, paramMap: params },
},
{
provide: GetProgramRoleWidgetUseCase,
useValue: {
execute: vi.fn(() => {
const response = new Subject<Result<ProgramRoleWidget, ProgramWidgetError>>();
responses.push(response);
return response;
}),
},
},
],
});
service = TestBed.inject(ProgramRoleWidgetService);
TestBed.flushEffects();
});
it("starts loading and returns server data", () => {
expect(service.state().status).toBe("loading");
responses[0].next(ok(result()));
expect(service.state()).toEqual({ status: "success", data: result() });
});
it("does not replace a failure with zero and permits explicit retry", () => {
responses[0].next(fail("network"));
expect(service.state()).toEqual({ status: "failure", error: "network", previous: undefined });
service.retry();
expect(responses).toHaveLength(2);
responses[1].next(ok(result()));
expect(service.state().status).toBe("success");
});
it.each(["forbidden", "unauthorized", "not_found", "integrity"] as const)(
"does not retry %s",
error => {
responses[0].next(fail(error));
service.retry();
expect(responses).toHaveLength(1);
},
);
it("clears on route changes before the resolver and ignores late responses", () => {
responses[0].next(ok(result()));
params.next(convertToParamMap({ programId: 13 }));
expect(service.state().status).toBe("loading");
TestBed.flushEffects();
responses[0].next(ok(result()));
expect(service.state().status).toBe("loading");
program.set(Object.assign(new Program(), program(), { id: 13 }));
TestBed.flushEffects();
responses[1].next(ok(result(13)));
expect(service.state()).toMatchObject({ status: "success", data: { programId: 13 } });
});
it("clears immediately on user and role changes", () => {
responses[0].next(ok(result()));
profile.set({ id: 8 });
expect(service.state().status).toBe("loading");
TestBed.flushEffects();
responses[0].next(ok(result()));
expect(service.state().status).toBe("loading");
program.set(Object.assign(new Program(), program(), { isUserExpert: true }));
TestBed.flushEffects();
expect(responses).toHaveLength(3);
});
it("refreshes on successful domain changes and cancels on logout", () => {
responses[0].next(ok(result()));
const events = TestBed.inject(EventBus);
events.emit({ type: "ProgramWidgetChanged", payload: {}, occurredAt: new Date() });
expect(responses).toHaveLength(2);
events.emit({ type: "LoggedOut", payload: {}, occurredAt: new Date() });
responses[1].next(ok(result()));
expect(service.state()).toMatchObject({ status: "failure", error: "unauthorized" });
profile.set({ id: 8 });
TestBed.flushEffects();
expect(responses).toHaveLength(3);
});
it("does not request data for an unrelated user", () => {
program.set(Object.assign(Program.default(), { id: 12 }));
TestBed.flushEffects();
expect(responses).toHaveLength(1);
responses[0].next(ok(result()));
expect(service.state()).toMatchObject({ status: "failure", error: "forbidden" });
});
it("rejects mismatched program data", () => {
responses[0].next(ok(result(14)));
expect(service.state()).toMatchObject({ status: "failure", error: "not_found" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/** @format */

import { computed, DestroyRef, effect, inject, Injectable, signal, untracked } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { AuthInfoService } from "@api/auth/facades/auth-info.service";
import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service";
import { GetProgramRoleWidgetUseCase } from "../../use-cases/get-program-role-widget.use-case";
import { programWidgetRole } from "../../program-widget-presentation";
import { ProgramRoleWidget, ProgramWidgetError } from "@domain/program/program-role-widget.model";
import { AsyncState, failure, loading, success } from "@domain/shared/async-state";
import { EventBus } from "@domain/shared/event-bus";
import { Subscription } from "rxjs";
import { User } from "@domain/auth/user.model";

/** Локальный запрос без кэша/polling. Старый ответ не переживает смену контекста или logout. */
@Injectable()
export class ProgramRoleWidgetService {
private readonly auth = inject(AuthInfoService);
private readonly programUI = inject(ProgramDetailMainUIInfoService);
private readonly request = inject(GetProgramRoleWidgetUseCase);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly events = inject(EventBus);
private readonly routeId = signal(Number(this.route.snapshot.paramMap.get("programId")));
private readonly rawState = signal<AsyncState<ProgramRoleWidget, ProgramWidgetError>>(loading());
private readonly loadedKey = signal("");
private readonly loggedOutProfile = signal<User | null | undefined>(undefined);
private activeRequest?: Subscription;
private version = 0;
private readonly key = computed(() => {
const program = this.programUI.program();
const profile = this.auth.profile();
const userId = profile !== this.loggedOutProfile() ? profile?.id : null;
const role = programWidgetRole(program);
return userId && role && program?.id === this.routeId()
? `${program.id}:${userId}:${role}`
: "";
});
readonly state = computed<AsyncState<ProgramRoleWidget, ProgramWidgetError>>(() => {
if (!this.auth.profile()?.id || this.auth.profile() === this.loggedOutProfile())
return failure("unauthorized");
const program = this.programUI.program();
if (program && !programWidgetRole(program)) return failure("forbidden");
return this.key() && this.loadedKey() === this.key()
? this.rawState()
: loading<ProgramRoleWidget>();
});
readonly programId = computed(() => this.programUI.program()?.id ?? 0);

constructor() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(params => this.routeId.set(Number(params.get("programId"))));
effect(() => {
const key = this.key();
untracked(() => this.load(key));
});
this.events
.on("LoggedOut")
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.loggedOutProfile.set(this.auth.profile());
this.load("");
});
this.events
.on("ProgramWidgetChanged")
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.load(this.key()));
this.destroyRef.onDestroy(() => {
this.version++;
this.activeRequest?.unsubscribe();
});
}
/** Повтор разрешён только для сетевого сбоя, без обхода отказа в доступе. */
retry(): void {
const state = this.state();
if (state.status === "failure" && state.error === "network") this.load(this.key());
}
private load(key: string): void {
const version = ++this.version;
this.activeRequest?.unsubscribe();
this.loadedKey.set(key);
this.rawState.set(loading());
if (!key) return;
const programId = this.programId();
this.activeRequest = this.request.execute(programId).subscribe(result => {
if (version !== this.version || key !== this.key()) return;
this.rawState.set(
result.ok
? result.value.programId === programId
? success(result.value)
: failure("not_found")
: failure(result.error),
);
});
}
}
Loading