diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md new file mode 100644 index 000000000..80c05429b --- /dev/null +++ b/docs/modules/program-management.md @@ -0,0 +1,42 @@ + + +# Управление программой · V1 + +Дизайн утверждён пользователем 17.10.2026 («В целом да»). +[Figma V1](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE?node-id=201-5390). + +Организатору доступны два входа вокруг аватара программы: «Оценка проектов» и «Управление программой». Последний открывает участников. Старые отдельные кнопки участников и проектов у организатора убраны. Эксперт без роли организатора сохраняет прежний маршрут оценивания. + +## Границы + +- Участники: таблица ФИО, почты, города и даты регистрации именно в программе; только чтение, поиск по имени/почте, серверная пагинация. +- Проекты: сдача и оценивание раздельно, кейс, руководитель и прямая ссылка на презентацию. Выбор одной строки открывает одну панель с просмотром команды и переходом к оценке. +- Команда: состав и роли; desktop side panel, mobile full screen. Закрытие возвращает фокус; возврат из оценки восстанавливает выбор и query списка. +- Оценивание: чтение оценок выбранного проекта. Существующий эксперт дополнительно получает переход в прежнюю форму с точным `project_id`. Роль организатора сама по себе не даёт право выставлять оценки. Несданное решение и неконкурсная программа блокируют переход из списка. +- Аналитика: существующий компонент и API, без изменения определений показателей. +- Настройки: Основное, Сроки, Материалы. Явное сохранение, предупреждение об уходе, сохранение введённых значений при ошибке, optimistic concurrency. Даты явно в Москве UTC+3. Повторное открытие истёкшего срока требует подтверждения сервера. +- Материалы: файл через существующий FileService либо URL; изменение и удаление записи программы сохраняют исходный UserFile. + +Индивидуальные действия с участниками, массовые операции, исключение проектов, возврат на доработку и смена кейса отложены. + +## Интеграция + +Новый приватный port/use case/repository/HTTP adapter использует `/programs/:id/management/`. CamelcaseInterceptor сохраняет существующее преобразование JSON. Почта и прочие приватные данные не попадают в общий EntityCache. После записи событие ProgramUpdated очищает кеш страницы и списка программ, включая persistent cache; старый ответ списка не заменяет новые данные. + +Backend проверяет manager именно этой программы либо существующий staff/superuser scope. Прямые чужие ID и эксперт без manager не получают доступ. Полный контракт находится в backend `docs/modules/program-management.md`. Backend PR должен попасть в dev раньше UI. + +Native adapters Button/Field/Choice/Table, существующие Modal/Icon/Pagination/Alert и Mont сохраняют PROCOLLAB DS. Foundation подключена локально также на desktop; глобальные компоненты не менялись. + +## Verification · 17.10.2026 + +52 теста Vitest в семи файлах: management facade, компонент, HTTP contract, утилиты дат/статусов, ProgramRepository, ProgramShellInfoService и существующий FileService. Проверены отмена устаревших запросов и сброс приватных данных при 403, выбор, dirty confirmation, версия записи, загрузка multipart через FileService и кеш после сохранения. + +Полный настроенный Vitest-прогон: **2117 PASS / 416 файлов**. Ожидание ссылки organizer role widget обновлено на `/management/analytics`; прежний экспертный маршрут сохранён. Общая проверка Prettier проходит с `--end-of-line auto` для Windows checkout с CRLF; изменённые файлы отдельно проходят обычный `--check`. + +`ng build social_platform --configuration=production` проходит. Сохраняются предупреждения Sass/CommonJS и budget warnings; ошибок сборки нет. Новая SCSS укладывается в error budget. Targeted ESLint, Stylelint и форматирование проверены отдельно. + +Headless Chrome, реальная production-сборка + локальный Django/PostgreSQL: участники, почта, no-results/reset, выбор и команда с возвратом фокуса, возврат из оценки, readonly manager, settings save/error/dirty leave, обновление публичной страницы, повторное открытие срока (cancel/save), material URL CRUD, аналитика, 403. Проверены desktop1440/mobile390/360; переполнения нет. Часовой пояс браузера America/Los_Angeles подтверждает независимость московских полей. + +Снимки локальной проверки: [участники desktop](../qa/program-management-v1/participants-desktop.png), [проекты mobile](../qa/program-management-v1/projects-mobile.png), [команда desktop](../qa/program-management-v1/team-desktop.png). Данные демонстрационные; внешний placeholder аватара заблокирован в тестовом браузере. + +Реальная загрузка в Selectel CDN, dev после merge и production не проверялись. FileService не менялся; ownership, переключение file→URL и сохранность исходного файла проверены backend tests. Нужна обычная приёмка на dev после обоих merge; merge/deploy выполняет пользователь. diff --git a/docs/qa/program-management-v1/participants-desktop.png b/docs/qa/program-management-v1/participants-desktop.png new file mode 100644 index 000000000..44feb46fc Binary files /dev/null and b/docs/qa/program-management-v1/participants-desktop.png differ diff --git a/docs/qa/program-management-v1/projects-mobile.png b/docs/qa/program-management-v1/projects-mobile.png new file mode 100644 index 000000000..8f7eb90fb Binary files /dev/null and b/docs/qa/program-management-v1/projects-mobile.png differ diff --git a/docs/qa/program-management-v1/team-desktop.png b/docs/qa/program-management-v1/team-desktop.png new file mode 100644 index 000000000..93f2b571f Binary files /dev/null and b/docs/qa/program-management-v1/team-desktop.png differ diff --git a/projects/social_platform/src/app/api/paths/app-routes.ts b/projects/social_platform/src/app/api/paths/app-routes.ts index d0dbd8d7d..f3d49c7d2 100644 --- a/projects/social_platform/src/app/api/paths/app-routes.ts +++ b/projects/social_platform/src/app/api/paths/app-routes.ts @@ -42,6 +42,8 @@ export const AppRoutes = { }, program: { + management: (programId: number | string, section: string = "participants") => + `/office/program/${programId}/management/${section}`, root: () => "/office/program", list: () => "/office/program/all", detail: (programId: number | string) => `/office/program/${programId}`, diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts index ccbed70fc..ddf64ce3e 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts @@ -416,6 +416,11 @@ export class ProgramDetailListInfoService { return; } + if (this.listType() === "rating" && key === "project_id") { + extraParams["project_id"] = value; + return; + } + if (this.listType() === "rating" && key === "is_rated_by_expert") { extraParams["is_rated_by_expert"] = value; return; diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-management.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-management.service.spec.ts new file mode 100644 index 000000000..caca37b1d --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-management.service.spec.ts @@ -0,0 +1,119 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { Subject, of, throwError } from "rxjs"; +import { ProgramManagementService, ManagementContext } from "./program-management.service"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; +import { + ManagementProject, + ManagementTeam, + ProgramManagement, +} from "@domain/program/program-management.model"; + +const program = { id: 1, name: "Программа", materials: [] } as unknown as ProgramManagement; +const first = { id: 11, projectId: 91, name: "Первый" } as ManagementProject; +const second = { id: 12, projectId: 92, name: "Второй" } as ManagementProject; +const page = (results: ManagementProject[]) => ({ + results, + count: results.length, + next: null, + previous: null, + caseOptions: [], + isCompetitive: true, +}); +const context = (id = 1): ManagementContext => ({ + programId: id, + section: "projects", + query: { search: "", limit: 25, offset: 0, submission: "all", caseScope: "all" }, +}); + +describe("ProgramManagementService", () => { + function setup() { + const api = { + get: vi.fn(() => of(program)), + projects: vi.fn(() => of(page([first, second]))), + participants: vi.fn(), + team: vi.fn(), + evaluation: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + ProgramManagementService, + ProgramDetailMainUIInfoService, + { provide: ProgramManagementUseCase, useValue: api }, + ], + }); + return { api, service: TestBed.inject(ProgramManagementService) }; + } + it("одновременно выбирает только один проект", () => { + const { service } = setup(); + service.load(context()); + service.select(first); + service.select(second); + expect(service.selected()?.id).toBe(second.id); + }); + it("очищает выбор, если проект исчез после фильтрации", () => { + const { service, api } = setup(); + service.load(context()); + service.select(first); + api.projects.mockReturnValue(of(page([second]))); + service.load({ ...context(), query: { ...context().query, search: "Второй" } }); + expect(service.selected()).toBeNull(); + }); + it("сохраняет выбор при обновлении той же выдачи", () => { + const { service } = setup(); + service.load(context()); + service.select(first); + service.retry(); + expect(service.selected()?.id).toBe(first.id); + }); + it("не применяет поздний ответ предыдущей программы", () => { + const { service, api } = setup(); + const old = new Subject(); + api.get.mockReturnValueOnce(old); + service.load(context()); + api.get.mockReturnValueOnce(of({ ...program, id: 2 })); + service.load(context(2)); + old.next(program); + old.complete(); + expect(service.program()?.id).toBe(2); + }); + it("не показывает команду прошлого выбора", () => { + const { service, api } = setup(); + const team = new Subject(); + api.team.mockReturnValue(team); + service.load(context()); + service.select(first); + service.openTeam(); + service.select(second); + team.next({ projectId: 91, programLinkId: 11, name: "Первый", members: [] }); + expect(service.team()).toBeNull(); + expect(service.teamOpen()).toBe(false); + }); + it("команда использует id связи программы, а не глобальный projectId", () => { + const { service, api } = setup(); + api.team.mockReturnValue(of({ members: [] })); + service.load(context()); + service.select(first); + service.openTeam(); + expect(api.team).toHaveBeenCalledWith(1, 11); + }); + it("после 403 удаляет ранее загруженные приватные данные", () => { + const { service, api } = setup(); + service.load(context()); + service.select(first); + api.get.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 403 }))); + service.retry(); + expect(service.program()).toBeNull(); + expect(service.projects()).toBeNull(); + expect(service.selected()).toBeNull(); + expect(service.error()?.status).toBe(403); + }); + it("восстанавливает выбор после возврата из оценок", () => { + const { service } = setup(); + service.load({ ...context(), selectedId: second.id }); + expect(service.selected()?.id).toBe(second.id); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-management.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-management.service.ts new file mode 100644 index 000000000..9d14671a7 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-management.service.ts @@ -0,0 +1,154 @@ +/** @format */ + +import { DestroyRef, Injectable, inject, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { HttpErrorResponse } from "@angular/common/http"; +import { Subject, catchError, forkJoin, map, of, switchMap, takeUntil, tap } from "rxjs"; +import { ProgramManagementUseCase } from "@api/program/use-cases/program-management.use-case"; +import { + ManagementEvaluation, + ManagementPage, + ManagementParticipant, + ManagementProject, + ManagementProjectsPage, + ManagementQuery, + ManagementSection, + ManagementTeam, + ProgramManagement, +} from "@domain/program/program-management.model"; +import { Program } from "@domain/program/program.model"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; + +export interface ManagementContext { + programId: number; + section: ManagementSection; + query: ManagementQuery; + linkId?: number; + selectedId?: number; +} +interface Content { + participants?: ManagementPage; + projects?: ManagementProjectsPage; + evaluation?: ManagementEvaluation; +} + +@Injectable() +export class ProgramManagementService { + readonly api = inject(ProgramManagementUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly programUI = inject(ProgramDetailMainUIInfoService); + private readonly requests = new Subject(); + private readonly cancelTeam = new Subject(); + readonly program = signal(null); + readonly participants = signal | null>(null); + readonly projects = signal(null); + readonly evaluation = signal(null); + readonly pending = signal(false); + readonly error = signal(null); + readonly selected = signal(null); + readonly team = signal(null); + readonly teamPending = signal(false); + readonly teamError = signal(null); + readonly teamOpen = signal(false); + context: ManagementContext | null = null; + + constructor() { + this.requests + .pipe( + tap(ctx => { + if (this.context?.programId !== ctx.programId) this.program.set(null); + if (this.context?.programId !== ctx.programId || this.context?.section !== ctx.section) + this.clearSelection(); + this.context = ctx; + this.pending.set(true); + this.error.set(null); + this.closeTeam(); + }), + switchMap(ctx => { + const content = + ctx.section === "participants" + ? this.api + .participants(ctx.programId, ctx.query) + .pipe(map(participants => ({ participants }) as Content)) + : ctx.section === "projects" + ? this.api + .projects(ctx.programId, ctx.query) + .pipe(map(projects => ({ projects }) as Content)) + : ctx.section === "evaluation" && ctx.linkId + ? this.api + .evaluation(ctx.programId, ctx.linkId) + .pipe(map(evaluation => ({ evaluation }) as Content)) + : of({} as Content); + return forkJoin({ program: this.api.get(ctx.programId), content }).pipe( + tap(({ program, content }) => { + this.program.set(program); + this.participants.set(content.participants ?? null); + this.projects.set(content.projects ?? null); + this.evaluation.set(content.evaluation ?? null); + const selectedId = ctx.selectedId ?? this.selected()?.id; + this.selected.set(content.projects?.results.find(p => p.id === selectedId) ?? null); + this.programUI.program.set( + Object.assign(new Program(), Program.default(), program, { isUserManager: true }), + ); + }), + catchError((error: HttpErrorResponse) => { + this.error.set(error); + this.program.set(null); + this.participants.set(null); + this.projects.set(null); + this.evaluation.set(null); + this.clearSelection(); + return of(null); + }), + tap(() => this.pending.set(false)), + ); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + load(ctx: ManagementContext): void { + this.requests.next(ctx); + } + retry(): void { + if (this.context) this.load(this.context); + } + select(project: ManagementProject): void { + this.closeTeam(); + this.selected.set(project); + } + clearSelection(): void { + this.closeTeam(); + this.selected.set(null); + } + openTeam(): void { + const project = this.selected(), + ctx = this.context; + if (!project || !ctx) return; + this.cancelTeam.next(); + this.team.set(null); + this.teamError.set(null); + this.teamOpen.set(true); + this.teamPending.set(true); + this.api + .team(ctx.programId, project.id) + .pipe(takeUntil(this.cancelTeam), takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: team => { + this.team.set(team); + this.teamPending.set(false); + }, + error: (error: HttpErrorResponse) => { + this.teamError.set(error); + this.teamPending.set(false); + }, + }); + } + closeTeam(): void { + this.cancelTeam.next(); + this.teamOpen.set(false); + this.teamPending.set(false); + this.team.set(null); + this.teamError.set(null); + } +} diff --git a/projects/social_platform/src/app/api/program/facades/program-shell-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/program-shell-info.service.spec.ts new file mode 100644 index 000000000..bcdb72e6b --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/program-shell-info.service.spec.ts @@ -0,0 +1,75 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +import { ProgramShellInfoService } from "./program-shell-info.service"; +import { ParticipatingProgramUseCase } from "../use-cases/participating-program.use-case"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProgramUpdated } from "@domain/program/program-updated.event"; +import { Program } from "@domain/program/program.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Result, ok } from "@domain/shared/result.type"; +import { readCache, writeCache } from "@utils/cache"; + +describe("Обновление кеша программ после сохранения организатором", () => { + let facade: ProgramShellInfoService; + let bus: EventBus; + let requests: Subject, unknown>>[]; + const result = (name: string) => + ok({ + count: 1, + next: "", + previous: "", + results: [Object.assign(new Program(), { id: 7, name })], + }); + beforeEach(() => { + TestBed.resetTestingModule(); + localStorage.clear(); + requests = []; + TestBed.configureTestingModule({ + providers: [ + ProgramShellInfoService, + EventBus, + { + provide: ParticipatingProgramUseCase, + useValue: { + execute: vi.fn(() => { + const request = new Subject, unknown>>(); + requests.push(request); + return request; + }), + }, + }, + ], + }); + facade = TestBed.inject(ProgramShellInfoService); + bus = TestBed.inject(EventBus); + }); + afterEach(() => { + TestBed.resetTestingModule(); + localStorage.clear(); + }); + const updated = (): ProgramUpdated => ({ + type: "ProgramUpdated", + payload: { programId: 7 }, + occurredAt: new Date(), + }); + it("очищает сохранённый кеш и запрашивает новое название", () => { + writeCache("programs", 1, [Object.assign(new Program(), { id: 7, name: "Старое" })]); + facade.ensureProgramsLoaded().subscribe(); + expect(facade.programs()[0].name).toBe("Старое"); + bus.emit(updated()); + expect(readCache("programs", 1, 3600000)).toBeNull(); + requests[1].next(result("Новое")); + expect(facade.programs()[0].name).toBe("Новое"); + }); + it("старый запрос не возвращает прежние значения после успешного обновления", () => { + facade.ensureProgramsLoaded().subscribe(); + bus.emit(updated()); + requests[1].next(result("Новое")); + requests[0].next(result("Старое")); + requests[0].complete(); + expect(facade.programs()[0].name).toBe("Новое"); + expect(readCache("programs", 1, 3600000)?.[0].name).toBe("Новое"); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts b/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts index 368fd2258..cc4540ce6 100644 --- a/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/program-shell-info.service.ts @@ -13,12 +13,14 @@ import { success, } from "@domain/shared/async-state"; import { Observable, finalize, shareReplay, of, tap } from "rxjs"; -import { readCache, writeCache } from "@utils/cache"; +import { clearCacheKey, readCache, writeCache } from "@utils/cache"; import { plainToInstance } from "class-transformer"; import { HttpParams } from "@angular/common/http"; import { ParticipatingProgramUseCase } from "../use-cases/participating-program.use-case"; import { Result, ok } from "@domain/shared/result.type"; import { ApiPagination } from "@domain/other/api-pagination.model"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProgramUpdated } from "@domain/program/program-updated.event"; const PROGRAM_CACHE_KEY = "programs"; const CACHE_VERSION = 1; @@ -29,6 +31,15 @@ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24h providedIn: "root", }) export class ProgramShellInfoService { + constructor() { + inject(EventBus) + .on("ProgramUpdated") + .subscribe(() => { + this.invalidatePrograms(); + clearCacheKey(PROGRAM_CACHE_KEY, CACHE_VERSION); + this.ensureProgramsLoaded().subscribe(); + }); + } private readonly participatingProgramUseCase = inject(ParticipatingProgramUseCase); private readonly programs$ = signal>(initial()); @@ -49,6 +60,7 @@ export class ProgramShellInfoService { private allProgramsInflight: Observable, unknown>> | null = null; private filteredInflight: Observable, unknown>> | null = null; + private cacheGeneration = 0; ensureProgramsLoaded(filter?: HttpParams): Observable, unknown>> { const isFiltered = !!filter && filter.keys().length > 0; @@ -94,6 +106,7 @@ export class ProgramShellInfoService { } invalidatePrograms(): void { + this.cacheGeneration++; this.allProgramsInflight = null; this.filteredInflight = null; this.programs$.set(initial()); @@ -107,23 +120,28 @@ export class ProgramShellInfoService { this.programs$.set(loading(prev)); const source$ = this.participatingProgramUseCase.execute(); + const generation = this.cacheGeneration; const request$ = source$.pipe( tap(result => { - if (!result.ok) return; + if (!result.ok || generation !== this.cacheGeneration) return; this.programs$.set(success(result.value.results)); try { writeCache(PROGRAM_CACHE_KEY, CACHE_VERSION, result.value.results); } catch {} }), - finalize(() => (this.allProgramsInflight = null)), + finalize(() => { + if (generation === this.cacheGeneration) this.allProgramsInflight = null; + }), shareReplay({ bufferSize: 1, refCount: true }), ); this.allProgramsInflight = request$; request$.subscribe({ - error: error => this.programs$.set(failure(error, prev)), + error: error => { + if (generation === this.cacheGeneration) this.programs$.set(failure(error, prev)); + }, }); return request$; 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 new file mode 100644 index 000000000..48aab261e --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-management.use-case.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port"; +import { + ManagementMaterial, + ManagementMaterialWrite, + ManagementQuery, + ManagementSettingsWrite, +} from "@domain/program/program-management.model"; + +@Injectable() +export class ProgramManagementUseCase { + private readonly repository = inject(ProgramManagementRepositoryPort); + get(id: number) { + return this.repository.get(id); + } + participants(id: number, q: ManagementQuery) { + return this.repository.participants(id, q); + } + projects(id: number, q: ManagementQuery) { + return this.repository.projects(id, q); + } + team(id: number, linkId: number) { + return this.repository.team(id, linkId); + } + evaluation(id: number, linkId: number) { + return this.repository.evaluation(id, linkId); + } + save(id: number, section: "basic" | "dates", body: ManagementSettingsWrite) { + return this.repository.save(id, section, body); + } + saveMaterial(id: number, body: ManagementMaterialWrite, materialId?: number) { + return this.repository.saveMaterial(id, body, materialId); + } + deleteMaterial(id: number, material: ManagementMaterial) { + return this.repository.deleteMaterial(id, material); + } +} 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 new file mode 100644 index 000000000..b170d2561 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/program-management.repository.port.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { + ManagementEvaluation, + ManagementMaterial, + ManagementMaterialWrite, + ManagementPage, + ManagementParticipant, + ManagementProjectsPage, + ManagementQuery, + ManagementSettingsWrite, + ManagementTeam, + ProgramManagement, +} from "../program-management.model"; + +export abstract class ProgramManagementRepositoryPort { + abstract get(programId: number): Observable; + abstract participants( + programId: number, + query: ManagementQuery, + ): Observable>; + abstract projects(programId: number, query: ManagementQuery): Observable; + abstract team(programId: number, linkId: number): Observable; + abstract evaluation(programId: number, linkId: number): Observable; + abstract save( + programId: number, + section: "basic" | "dates", + body: ManagementSettingsWrite, + ): Observable; + abstract saveMaterial( + programId: number, + body: ManagementMaterialWrite, + id?: number, + ): Observable; + abstract deleteMaterial(programId: number, material: ManagementMaterial): Observable; +} 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 new file mode 100644 index 000000000..f5ba6e5e7 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -0,0 +1,115 @@ +/** @format */ + +export type ManagementSection = + | "participants" + | "projects" + | "analytics" + | "settings" + | "evaluation"; +export type SettingsSection = "basic" | "dates" | "materials"; +export interface ManagementMaterial { + id: number; + title: string; + url: string; + file: { link: string; name: string; extension: string; size: number } | null; + updatedAt: string; +} +export interface ManagementBasic { + name: string; + city: string; + tag: string; + description: string | null; + imageAddress: string | null; + coverImageAddress: string | null; + advertisementImageAddress: string | null; + registrationLink: string | null; + presentationAddress: string | null; +} +export interface ManagementDates { + datetimeStarted: string; + datetimeRegistrationEnds: string; + datetimeProjectSubmissionEnds: string | null; + datetimeEvaluationEnds: string | null; + datetimeFinished: string; +} +export interface ProgramManagement extends ManagementBasic, ManagementDates { + id: number; + updatedAt: string; + isCompetitive: boolean; + isDistributedEvaluation: boolean; + materials: ManagementMaterial[]; +} +export interface ManagementParticipant { + id: number; + userId: number; + name: string; + email: string | null; + city: string | null; + registeredAt: string; +} +export type ManagementEvaluationStatus = + | "not_submitted" + | "awaiting_evaluation" + | "partially_evaluated" + | "evaluated" + | "not_applicable"; +export interface ManagementProject { + id: number; + projectId: number; + name: string; + leader: { id: number; name: string }; + submitted: boolean; + evaluationStatus: ManagementEvaluationStatus; + assignmentsTotal: number | null; + assignmentsCompleted: number | null; + caseName: string | null; + presentationUrl: string | null; +} +export interface ManagementPage { + count: number; + results: T[]; + next: string | null; + previous: string | null; +} +export interface ManagementProjectsPage extends ManagementPage { + caseOptions: string[]; + isCompetitive: boolean; +} +export interface ManagementTeam { + projectId: number; + programLinkId: number; + name: string; + members: { id: number; name: string; role: string }[]; +} +export interface ManagementEvaluation { + projectId: number; + programLinkId: number; + name: string; + submitted: boolean; + canRate: boolean; + experts: { + id: number; + name: string; + status: string; + criteria: { id: number; name: string; type: string; value: string | null; isScored: boolean }[]; + }[]; +} +export interface ManagementQuery { + search: string; + offset: number; + limit: number; + submission: "all" | "submitted" | "not_submitted"; + caseScope: "all" | "selected" | "without_case"; + caseName?: string; +} +export interface ManagementSettingsWrite { + expectedUpdatedAt: string; + confirmReopen?: boolean; + [field: string]: string | boolean | null | undefined; +} +export interface ManagementMaterialWrite { + title: string; + url: string | null; + file: string | null; + expectedUpdatedAt?: string; +} diff --git a/projects/social_platform/src/app/domain/program/program-updated.event.ts b/projects/social_platform/src/app/domain/program/program-updated.event.ts new file mode 100644 index 000000000..31e5d3a51 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-updated.event.ts @@ -0,0 +1,7 @@ +/** @format */ + +import { DomainEvent } from "@domain/shared/domain-event"; +export interface ProgramUpdated extends DomainEvent { + type: "ProgramUpdated"; + payload: { programId: number }; +} 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 new file mode 100644 index 000000000..5567fff22 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.spec.ts @@ -0,0 +1,77 @@ +/** @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 { CamelcaseInterceptor } from "@core/lib/interceptors/camelcase.interceptor"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProgramManagementHttpAdapter } from "./program-management-http.adapter"; +import { ManagementMaterial } from "@domain/program/program-management.model"; +import { API_URL } from "@core/lib/providers"; + +describe("Контракт HTTP панели организатора", () => { + let adapter: ProgramManagementHttpAdapter, http: HttpTestingController; + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: API_URL, useValue: "https://local.test" }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ProgramManagementHttpAdapter, + { provide: LoggerService, useValue: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() } }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + ], + }); + adapter = TestBed.inject(ProgramManagementHttpAdapter); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => { + http?.verify(); + TestBed.resetTestingModule(); + }); + it("фильтры передаются серверу в snake_case, ответ преобразуется в camelCase", () => { + adapter + .projects(4, { + search: "Город", + offset: 25, + limit: 25, + submission: "submitted", + caseScope: "selected", + caseName: "Экология", + }) + .subscribe(page => expect(page.caseOptions).toEqual(["Экология"])); + const request = http.expectOne(r => r.url.endsWith("/programs/4/management/projects/")); + expect(request.request.params.get("case_scope")).toBe("selected"); + expect(request.request.params.get("case_name")).toBe("Экология"); + expect(request.request.params.get("offset")).toBe("25"); + request.flush({ count: 0, results: [], case_options: ["Экология"], is_competitive: true }); + }); + it("версия PATCH не теряет точность при преобразовании JSON", () => { + const version = "2026-10-17T12:34:56.123456Z"; + adapter + .save(4, "dates", { + expectedUpdatedAt: version, + datetimeProjectSubmissionEnds: null, + confirmReopen: true, + }) + .subscribe(p => expect(p.updatedAt).toBe(version)); + const request = http.expectOne(r => r.url.endsWith("/management/settings/dates/")); + expect(request.request.method).toBe("PATCH"); + expect(request.request.body).toEqual({ + expected_updated_at: version, + datetime_project_submission_ends: null, + confirm_reopen: true, + }); + request.flush({ updated_at: version }); + }); + it("удаление материала передаёт версию в query и не вызывает удаление UserFile", () => { + adapter + .deleteMaterial(4, { id: 9, updatedAt: "2026-10-17T15:00:00+03:00" } as ManagementMaterial) + .subscribe(); + const request = http.expectOne(r => r.url.endsWith("/programs/4/management/materials/9/")); + expect(request.request.method).toBe("DELETE"); + expect(request.request.params.get("expected_updated_at")).toBe("2026-10-17T15:00:00+03:00"); + request.flush(null, { status: 204, statusText: "No Content" }); + }); +}); 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 new file mode 100644 index 000000000..97cb816e4 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-management-http.adapter.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { HttpParams } from "@angular/common/http"; +import { ApiService } from "@corelib"; +import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port"; +import { + ManagementEvaluation, + ManagementMaterial, + ManagementMaterialWrite, + ManagementPage, + ManagementParticipant, + ManagementProjectsPage, + ManagementQuery, + ManagementSettingsWrite, + ManagementTeam, + ProgramManagement, +} from "@domain/program/program-management.model"; + +@Injectable() +export class ProgramManagementHttpAdapter implements ProgramManagementRepositoryPort { + private readonly api = inject(ApiService); + private root(id: number): string { + return `/programs/${id}/management`; + } + get(id: number) { + return this.api.get(`${this.root(id)}/`); + } + participants(id: number, q: ManagementQuery) { + return this.api.get>( + `${this.root(id)}/participants/`, + this.params(q), + ); + } + projects(id: number, q: ManagementQuery) { + let params = this.params(q).set("submission", q.submission).set("case_scope", q.caseScope); + if (q.caseScope === "selected" && q.caseName) params = params.set("case_name", q.caseName); + return this.api.get(`${this.root(id)}/projects/`, params); + } + team(id: number, linkId: number) { + return this.api.get(`${this.root(id)}/projects/${linkId}/team/`); + } + evaluation(id: number, linkId: number) { + return this.api.get(`${this.root(id)}/projects/${linkId}/evaluation/`); + } + save(id: number, section: "basic" | "dates", body: ManagementSettingsWrite) { + return this.api.patch(`${this.root(id)}/settings/${section}/`, body); + } + saveMaterial(id: number, body: ManagementMaterialWrite, materialId?: number) { + return materialId + ? this.api.patch(`${this.root(id)}/materials/${materialId}/`, body) + : this.api.post(`${this.root(id)}/materials/`, body); + } + deleteMaterial(id: number, material: ManagementMaterial) { + return this.api.delete( + `${this.root(id)}/materials/${material.id}/`, + new HttpParams().set("expected_updated_at", material.updatedAt), + ); + } + private params(q: ManagementQuery): HttpParams { + return new HttpParams().set("search", q.search).set("limit", q.limit).set("offset", q.offset); + } +} 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 new file mode 100644 index 000000000..09d130f96 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-management.repository.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { tap } from "rxjs"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProgramManagementRepositoryPort } from "@domain/program/ports/program-management.repository.port"; +import { ProgramManagementHttpAdapter } from "@infrastructure/adapters/program/program-management-http.adapter"; +import { + ManagementMaterial, + ManagementMaterialWrite, + ManagementQuery, + ManagementSettingsWrite, +} from "@domain/program/program-management.model"; + +/** Приватные manager-данные не попадают в общий EntityCache программы. */ +@Injectable() +export class ProgramManagementRepository implements ProgramManagementRepositoryPort { + private readonly adapter = inject(ProgramManagementHttpAdapter); + private readonly events = inject(EventBus); + get(id: number) { + return this.adapter.get(id); + } + participants(id: number, q: ManagementQuery) { + return this.adapter.participants(id, q); + } + projects(id: number, q: ManagementQuery) { + return this.adapter.projects(id, q); + } + team(id: number, linkId: number) { + return this.adapter.team(id, linkId); + } + evaluation(id: number, linkId: number) { + return this.adapter.evaluation(id, linkId); + } + save(id: number, section: "basic" | "dates", body: ManagementSettingsWrite) { + return this.adapter.save(id, section, body).pipe(tap(() => this.changed(id))); + } + saveMaterial(id: number, body: ManagementMaterialWrite, materialId?: number) { + return this.adapter.saveMaterial(id, body, materialId).pipe(tap(() => this.changed(id))); + } + deleteMaterial(id: number, material: ManagementMaterial) { + return this.adapter.deleteMaterial(id, material).pipe(tap(() => this.changed(id))); + } + private changed(id: number): void { + this.events.emit({ + type: "ProgramUpdated", + payload: { programId: id }, + occurredAt: new Date(), + }); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index 301c2292e..1e6a364e8 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -24,6 +24,7 @@ import { ApplyToProgramResponse } from "@domain/program/results/apply-to-program import { userFromRaw } from "@utils/userRaw"; import { EventBus } from "@domain/shared/event-bus"; import { LoggedOut } from "@domain/auth/events/logged-out.event"; +import { ProgramUpdated } from "@domain/program/program-updated.event"; import { ProgramAnalyticsAttentionPage, ProgramAnalyticsAttentionParticipant, @@ -65,6 +66,9 @@ export class ProgramRepository implements ProgramRepositoryPort { } private initializeEvents(): void { + this.eventBus + .on("ProgramUpdated") + .subscribe(event => this.entityCache.invalidate(event.payload.programId)); this.eventBus.on("LoggedOut").subscribe({ next: () => { this.entityCache.clear(); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html index d6d0d5e90..92603454a 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/role-widget/program-role-widget.component.html @@ -160,7 +160,7 @@

аналитика