Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/expert-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<!-- @format -->

# Эксперты и личное оценивание

Организатор получает пятую вкладку «Эксперты» в управлении программой: приглашения
по email, состав и нагрузку, ручное назначение проектов, предпросмотр и применение
автораспределения, изменение необходимого числа оценщиков.

Макеты:

- [Управление экспертами](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=293-3220)
- [Путь приглашённого эксперта](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=314-3220)
- [Оценивание — Мои программы](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=331-3220)

## Переходы и роли

`/office/evaluation` всегда открывает «Мои программы», даже с одной программой.
Кнопка программы «Оценка проектов» открывает непосредственно её
`/office/program/{id}/projects-rating`; карточка в кабинете ведёт туда же.

Меню доступно принявшим приглашение экспертам и пользователям с действующим
приглашением на их email. При нуле назначений вход остаётся, показано пустое
состояние. Обычный участник не видит оценивание; организатор видит управление;
эксперт — оценку; совмещающий обе роли — обе кнопки. Роль проверяется в текущей
программе, глобальный user_type не является источником разрешения.

Права и ограничения повторно проверяются backend при прямом запросе.
Принятие приглашения сбрасывает кеш сведений о программе и обновляет профиль.
Возврат в кабинет после оценки обновляет личные счётчики.

## Приглашение

`/expert-invitation/{token}` находится вне Office, чтобы новый пользователь мог
пройти путь до входа. Открытие ссылки ничего не создаёт и не принимает.

Новый аккаунт: фиксированный email, имя, фамилия, пароль/повтор и действующие
согласия платформы (не отмечены заранее). Даты рождения в этом сценарии нет.
Создание и принятие атомарны; повторное подтверждение email не отправляется.
Обязательный onboarding не перекрывает переход к оценке, но остальные сценарии
платформы сохраняют свои требования.

Существующий аккаунт: вход, затем отдельное принятие приглашения. Другая почта
показывает объяснение и смену аккаунта. Восстановление пароля сохраняет контекст
приглашения, после смены пароля возвращает на вход, не авторизует автоматически.
Ссылка из письма открывает `/expert-password-reset/` в той же Auth-оболочке;
этот SPA route не пересекается с API `/auth/` на совмещённом DEV-host.
Просроченная/отозванная ссылка и повторное принятие имеют отдельные состояния.
На странице приглашения действует no-referrer; ссылка не передаётся в Referer.

## UI и ограничения

Используются существующие Modal/CDK focus trap, Button/Field/Choice/Avatar/Alert,
Mont, семантические токены и текущая оболочка. Все новые модалки центрированы,
высота ограничена viewport, длинные списки прокручиваются внутри. Desktop —
компактная таблица, 390/360 — карточки. Выбор строки/кружка повторным нажатием
снимается. Поиск сохраняет фокус и текст; выбранный кейс остаётся в select.

Защищённые назначения с оценками не снимаются. При 409 выбор остаётся в редакторе;
нужно обновить данные перед повторной записью. Pending не доступен для назначения.
Автораспределение заполняет дефицит до N, исключает свои команды, сохраняет текущие
назначения и показывает нагрузку до/после. Нехватка кандидатов блокирует применение.
Изменение N предварительно показывает влияние на готовность; оценки не удаляются.

## API и rollout

Новый `EvaluationRepositoryPort` → HTTP adapter/repository → use case → UI; доменная
логика не размещается в компонентах. Используется существующее преобразование
snake_case/camelCase. Контракт и миграции — `docs/expert-workspace.md` в API repo.
Backend должен быть выложен раньше Angular. Новая таблица приглашений и nullable
birthday обязательны до открытия новых маршрутов.

Новый кабинет показывает назначения (`assigned_only=1`), поэтому существующим
экспертам перед началом работы следует назначить проекты через новую вкладку.
Backend сохраняет прежний open mode для старых клиентов до явного включения
управляемого режима организатором. Production/React rollout в эту задачу не входит.

AC1–AC9 и правила объединены в implementation spec. Feature tests находятся рядом
с ExpertsComponent и ExpertInvitationComponent; рейтинг проверяется существующим
RatingCardService suite. Browser acceptance использует настоящий изолированный
Django/PostgreSQL, синтетические аккаунты и locmem mail; реальных писем не отправляет.
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ export class AuthPasswordService {
.pipe(
tap(result => {
if (result.ok) {
const invitation = this.route.snapshot.queryParamMap.get("expert_invitation");
if (invitation && /^[A-Za-z0-9_-]{20,100}$/.test(invitation)) {
void this.router.navigate(["/expert-invitation", invitation], {
queryParams: { mode: "login" },
});
return;
}
this.router
.navigateByUrl(AppRoutes.auth.login())
.then(() => this.logger.debug("SetPasswordComponent"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,16 @@ export class OfficeInfoService {
)
.subscribe(profile => {
this.officeUIInfoService.applyCreateNavItems(profile!.id);
if (!profile!.doesCompleted()) {
const expertRoute =
this.router.url.startsWith("/office/evaluation") ||
/^\/office\/program\/\d+\/projects-rating(?:[/?]|$)/.test(this.router.url);
const expertEntry = expertRoute && profile!.relations.hasEvaluationAccess === true;
if (!profile!.doesCompleted() && !expertEntry) {
this.router
.navigateByUrl(AppRoutes.onboarding.root())
.then(() => this.logger.debug("Route changed from OfficeComponent"));
} else if (
!expertEntry &&
profile!.relations.verificationDate === null &&
profile!.relations.verificationNoticeAcknowledgedAt === null
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** @format */
import { DestroyRef, Injectable, computed, effect, inject, signal, untracked } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { EvaluationUseCase } from "../use-cases/evaluation.use-case";
import { EvaluationProgram } from "@domain/program/evaluation-workspace.model";
import { ProfileInfoService } from "@api/profile/facades/profile-info.service";

@Injectable({ providedIn: "root" })
export class EvaluationStateService {
private readonly api = inject(EvaluationUseCase);
private readonly profiles = inject(ProfileInfoService);
private readonly destroyRef = inject(DestroyRef);
readonly programs = signal<EvaluationProgram[]>([]);
readonly pending = signal(false);
readonly failed = signal(false);
readonly hasAccess = computed(() => this.programs().length > 0);
private accountId: number | null = null;
private sequence = 0;
constructor() {
effect(() => {
const id = this.profiles.profile()?.id ?? null;
untracked(() => {
if (id === this.accountId) return;
this.accountId = id;
this.sequence++;
this.programs.set([]);
this.failed.set(false);
if (id) this.reload();
});
});
}
reload(): void {
const sequence = ++this.sequence;
this.pending.set(true);
this.failed.set(false);
this.api
.programs()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: data => {
if (sequence !== this.sequence) return;
this.programs.set(data.results);
this.pending.set(false);
},
error: () => {
if (sequence !== this.sequence) return;
this.failed.set(true);
this.pending.set(false);
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** @format */
import { inject, Injectable } from "@angular/core";
import { EvaluationRepositoryPort } from "@domain/program/ports/evaluation.repository.port";
import { EventBus } from "@domain/shared/event-bus";
import { ProgramUpdated } from "@domain/program/program-updated.event";
import { ExpertRegistration } from "@domain/program/evaluation-workspace.model";
import { tap } from "rxjs";
@Injectable({ providedIn: "root" })
export class EvaluationUseCase {
private readonly repository = inject(EvaluationRepositoryPort);
private readonly events = inject(EventBus);
private updated(programId: number): void {
this.events.emit<ProgramUpdated>({
type: "ProgramUpdated",
payload: { programId },
occurredAt: new Date(),
});
}
readonly programs = this.repository.programs.bind(this.repository);
readonly workspace = this.repository.workspace.bind(this.repository);
readonly invite = this.repository.invite.bind(this.repository);
readonly invitationAction = this.repository.invitationAction.bind(this.repository);
readonly assignments = this.repository.assignments.bind(this.repository);
readonly saveAssignments = this.repository.saveAssignments.bind(this.repository);
readonly allocation = this.repository.allocation.bind(this.repository);
readonly applyAllocation = this.repository.applyAllocation.bind(this.repository);
readonly quota = this.repository.quota.bind(this.repository);
readonly saveQuota = this.repository.saveQuota.bind(this.repository);
readonly invitation = this.repository.invitation.bind(this.repository);
accept(token: string) {
return this.repository.accept(token).pipe(tap(r => this.updated(r.programId)));
}
acceptOwn(id: number) {
return this.repository.acceptOwn(id).pipe(tap(r => this.updated(r.programId)));
}
register(token: string, data: ExpertRegistration) {
return this.repository.register(token, data).pipe(tap(r => this.updated(r.programId)));
}
readonly resetPassword = this.repository.resetPassword.bind(this.repository);
}
2 changes: 2 additions & 0 deletions projects/social_platform/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { BrowserModule } from "@angular/platform-browser";
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http";
import { provideRouter, withRouterConfig } from "@angular/router";
import { APP_ROUTES } from "./app.routes";
import { EVALUATION_PROVIDERS } from "./infrastructure/di/program/evaluation.providers";
import {
API_URL,
BearerTokenInterceptor,
Expand Down Expand Up @@ -95,6 +96,7 @@ export const APP_CONFIG: ApplicationConfig = {
),
provideAnimations(),
...AUTH_PROVIDERS,
...EVALUATION_PROVIDERS,
...FEED_PROVIDERS,
...INDUSTRY_PROVIDERS,
...INVITE_PROVIDERS,
Expand Down
20 changes: 20 additions & 0 deletions projects/social_platform/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ import { AuthRequiredGuard } from "@core/lib/guards/auth/auth-required.guard";
*/

export const APP_ROUTES: Routes = [
{
path: "expert-password-reset",
loadComponent: () => import("./ui/pages/auth/auth.component").then(c => c.AuthComponent),
children: [
{
path: "",
loadComponent: () =>
import("./ui/pages/auth/set-password/set-password.component").then(
c => c.SetPasswordComponent,
),
},
],
},
{
path: "expert-invitation/:token",
loadComponent: () =>
import("./ui/pages/evaluation/invitation/expert-invitation.component").then(
c => c.ExpertInvitationComponent,
),
},
{
path: "",
component: AppComponent,
Expand Down
1 change: 1 addition & 0 deletions projects/social_platform/src/app/domain/auth/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export class UserSubscription {
}

export class UserRelations {
hasEvaluationAccess?: boolean;
education!: Education[];
userLanguages!: UserLanguages[];
workExperience!: WorkExperience[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/** @format */
export interface ExpertRow {
id: number | string;
userId?: number;
invitationId?: number;
name: string | null;
email: string;
avatar?: string | null;
status: "joined" | "pending" | "expired" | "revoked";
deliveryStatus?: "queued" | "sent" | "failed";
assigned: number | null;
completed: number | null;
remaining: number | null;
}
export interface ExpertWorkspace {
revision: string;
requiredEvaluators: number;
managedEvaluation: boolean;
submittedProjects: number;
missingAssignments: number;
readyProjects: number;
experts: ExpertRow[];
}
export interface AssignmentProject {
id: number;
name: string;
caseName: string | null;
submitted: boolean;
assigned: boolean;
protected: boolean;
completed: boolean;
ownTeam: boolean;
}
export interface AssignmentOptions {
revision: string;
projects: AssignmentProject[];
}
export interface AllocationQuery {
expertIds: number[];
projectIds?: number[];
}
export interface AllocationPlan {
revision: string;
planToken: string;
assignments: { projectId: number; projectName: string; expertId: number; expertName: string }[];
shortages: { projectId: number; projectName: string; missing: number }[];
load: { expertId: number; name: string; before: number; after: number }[];
}
export interface QuotaPreview {
revision: string;
requiredEvaluators: number;
readyBefore: number;
readyAfter: number;
missingAssignments: number;
surplusAssignments: number;
}
export interface EvaluationProgram {
id: number;
name: string;
imageAddress: string | null;
imageFraming?: import("./program-image-framing.model").ProgramImageFraming;
datetimeEvaluationEnds: string | null;
status: "joined" | "pending";
email?: string;
invitationId?: number;
assigned?: number;
completed?: number;
remaining?: number;
}
export interface EvaluationPrograms {
results: EvaluationProgram[];
hasAccess: boolean;
}
export interface ExpertInvitation {
id: number;
email: string;
status: "pending" | "accepted" | "expired" | "revoked";
expiresAt: string;
program: EvaluationProgram;
accountExists: boolean;
loginEmail?: string;
matchesAccount: boolean;
}
export interface ExpertRegistration {
firstName: string;
lastName: string;
password: string;
repeatPassword: string;
acceptPolicy: boolean;
acceptAge: boolean;
}
export interface InvitationAccepted {
programId: number;
accepted: boolean;
}
export interface ExpertRegistered {
access: string;
refresh: string;
programId: number;
}
Loading