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
35 changes: 35 additions & 0 deletions docs/feature-specs/program-certificates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!-- @format -->

# Сертификаты в управлении программой

Источник: утвержденная пользователем страница Figma `904:3219` в
`tvog0fpEgWgEtr7KUTjbyE`. Шаблон `904:3220`, автовыдача `904:3221`,
журнал ошибок `940:4465`, редактор подписи `916:4155`.

В существующее управление добавляется вкладка **Сертификаты** с тремя шагами:
шаблон, автовыдача, отправки. Angular 20, действующие Mont и UI foundation.
Состояние и приватные данные не попадают в глобальный EntityCache.

Фон PNG/JPG, прозрачная подпись PNG, директор и должность. Элементы перемещаются
мышью/касанием или стрелками, размер меняется за угол. Подпись сохраняет
пропорции. Нет формы координат. Доступны центрирование, возврат к сохраненному
размещению, увеличенный редактор, проверка длинного имени и пробный PDF.

Два режима: все зарегистрированные либо участники команд со сданным решением
в текущей программе. Нет дополнительной проверки почты. Запуск вручную или
по дате после завершения; подтверждение, пробное письмо только себе, пауза.
Все адресаты и документы поступают из API, без демонстрационных участников.

Журнал: независимые готовность PDF и отправка, Все/Отправлено/Ошибки,
серверный поиск/пагинация, повтор одной или всех ошибок, архив по фильтру.
Повтор не создает новый PDF и не затрагивает отправленные письма.
Неясный бейдж «Включено» заменен явной строкой управления очередью.

Контракт: парный backend PR, `docs/modules/program-certificates.md`.
Маршрут: `/office/program/:programId/management/certificates`.
На странице программы участнику показывается ссылка на свой PDF.

Приемка: роли и чужая программа; оба режима получателей; PNG/JPEG и лимит;
drag/resize/границы/клавиатура; длинная кириллица в A4 PDF; сохранение и 409;
расписание/пауза/повтор; фильтры и пустые/ошибочные состояния; мобильная ширина;
защита несохраненных изменений; сборка Angular и проверки backend.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** @format */
import { inject, Injectable } from "@angular/core";
import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port";
@Injectable()
export class ProgramCertificatesUseCase {
private readonly repository = inject(ProgramCertificatesRepositoryPort);
readonly settings = this.repository.settings.bind(this.repository);
readonly save = this.repository.save.bind(this.repository);
readonly upload = this.repository.upload.bind(this.repository);
readonly asset = this.repository.asset.bind(this.repository);
readonly preview = this.repository.preview.bind(this.repository);
readonly test = this.repository.test.bind(this.repository);
readonly activate = this.repository.activate.bind(this.repository);
readonly queue = this.repository.queue.bind(this.repository);
readonly journal = this.repository.journal.bind(this.repository);
readonly retry = this.repository.retry.bind(this.repository);
readonly pdf = this.repository.pdf.bind(this.repository);
readonly archive = this.repository.archive.bind(this.repository);
readonly mine = this.repository.mine.bind(this.repository);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** @format */
import { Observable } from "rxjs";
import {
CertificateAsset,
CertificateDraft,
CertificateFilter,
CertificateJournal,
CertificateSettings,
} from "../program-certificates.model";
export abstract class ProgramCertificatesRepositoryPort {
abstract settings(id: number): Observable<CertificateSettings>;
abstract save(
id: number,
revision: number,
draft: CertificateDraft,
): Observable<CertificateSettings>;
abstract upload(
id: number,
kind: "background" | "signature",
file: File,
): Observable<CertificateAsset>;
abstract asset(id: number, assetId: number): Observable<Blob>;
abstract preview(id: number, revision: number): Observable<Blob>;
abstract test(id: number, revision: number): Observable<{ detail: string }>;
abstract activate(id: number, revision: number): Observable<CertificateSettings>;
abstract queue(id: number, action: "pause" | "resume"): Observable<CertificateSettings>;
abstract journal(
id: number,
status: CertificateFilter,
search: string,
offset: number,
): Observable<CertificateJournal>;
abstract retry(id: number, ids?: string[]): Observable<{ count: number }>;
abstract pdf(id: number, certificateId: string): Observable<Blob>;
abstract archive(id: number, status: CertificateFilter, search: string): Observable<Blob>;
abstract mine(id: number): Observable<{ id: string; issuedAt: string; url: string }[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** @format */
export type CertificateElement =
| "title"
| "subtitle"
| "name"
| "program"
| "date"
| "number"
| "director"
| "position"
| "signature";
export interface CertificateBox {
x: number;
y: number;
width: number;
height: number;
fontSize: number;
}
export interface CertificateAsset {
id: number;
name: string;
width: number;
height: number;
}
export interface CertificateDraft {
backgroundId: number | null;
signatureId: number | null;
director: string;
position: string;
showText: boolean;
audience: "all" | "submitted";
timing: "manual" | "scheduled";
scheduledAt: string | null;
sendEmail: boolean;
subject: string;
message: string;
layout: Record<CertificateElement, CertificateBox>;
}
export interface CertificateSettings {
revision: number;
draft: CertificateDraft;
assets: { background: CertificateAsset | null; signature: CertificateAsset | null };
eligibleCounts: { all: number; submitted: number };
batch: { id: number; active: boolean; issueAt: string; initializedAt: string | null } | null;
}
export type CertificateFilter = "all" | "sent" | "failed";
export interface CertificateDelivery {
id: string;
name: string;
email: string;
status: "pending" | "generating" | "sending" | "sent" | "failed" | "ready";
pdfReady: boolean;
error: string;
sentAt: string | null;
}
export interface CertificateJournal {
count: number;
results: CertificateDelivery[];
summary: { total: number; ready: number; sent: number; failed: number };
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ManagementSection =
| "experts"
| "results"
| "analytics"
| "certificates"
| "settings"
| "evaluation";
export type SettingsSection =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/** @format */
import { inject, Injectable } from "@angular/core";
import { HttpParams } from "@angular/common/http";
import { ApiService } from "@corelib";
import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port";
import {
CertificateAsset,
CertificateDraft,
CertificateFilter,
CertificateJournal,
CertificateSettings,
} from "@domain/program/program-certificates.model";
@Injectable()
export class ProgramCertificatesHttpAdapter implements ProgramCertificatesRepositoryPort {
private readonly api = inject(ApiService);
private root(id: number) {
return `/programs/${id}/management/certificates/`;
}
settings(id: number) {
return this.api.get<CertificateSettings>(this.root(id));
}
save(id: number, revision: number, draft: CertificateDraft) {
return this.api.put<CertificateSettings>(this.root(id), { revision, draft });
}
upload(id: number, kind: "background" | "signature", file: File) {
const body = new FormData();
body.append("file", file);
body.append("kind", kind);
return this.api.post<CertificateAsset>(this.root(id) + "assets/", body);
}
asset(id: number, assetId: number) {
return this.api.getFile(this.root(id) + `assets/${assetId}/`);
}
preview(id: number, revision: number) {
return this.api.postFile(this.root(id) + "preview/", { revision });
}
test(id: number, revision: number) {
return this.api.post<{ detail: string }>(this.root(id) + "test/", { revision });
}
activate(id: number, revision: number) {
return this.api.post<CertificateSettings>(this.root(id) + "activate/", { revision });
}
queue(id: number, action: "pause" | "resume") {
return this.api.post<CertificateSettings>(this.root(id) + `queue/${action}/`, {});
}
private query(status: CertificateFilter, search: string) {
return new HttpParams().set("status", status).set("search", search);
}
journal(id: number, status: CertificateFilter, search: string, offset: number) {
return this.api.get<CertificateJournal>(
this.root(id) + "deliveries/",
this.query(status, search).set("offset", offset).set("limit", 20),
);
}
retry(id: number, ids?: string[]) {
return this.api.post<{ count: number }>(this.root(id) + "retry/", ids ? { ids } : {});
}
pdf(id: number, certificateId: string) {
return this.api.getFile(this.root(id) + `deliveries/${certificateId}/pdf/`);
}
archive(id: number, status: CertificateFilter, search: string) {
return this.api.getFile(this.root(id) + "archive/", this.query(status, search));
}
mine(id: number) {
return this.api.get<{ id: string; issuedAt: string; url: string }[]>(
`/programs/${id}/certificates/me/`,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
</div>

<div class="program__content">
@if (program()!.isUserMember) {
<app-my-certificate [programId]="program()!.id" />
}
<div class="about program__about">
<div class="about__head">
<h3 class="text-body-12 about__title">о программе</h3>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { AppRoutes } from "@api/paths/app-routes";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { finalize } from "rxjs";
import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.component";
import { MyCertificateComponent } from "./my-certificate.component";

/** Страница основной вкладки программы с описанием, ссылками и новостями. */
@Component({
Expand All @@ -51,6 +52,7 @@ import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.c
RouterModule,
NewsCardComponent,
ProgramLinksComponent,
MyCertificateComponent,
],
providers: [ProgramDetailMainService, ExpandService],
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { By } from "@angular/platform-browser";
import { BehaviorSubject, of } from "rxjs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProgramDetailMainComponent } from "./main.component";
import { MyCertificateComponent } from "./my-certificate.component";
import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case";
import { ProgramRoleWidgetComponent } from "./role-widget/program-role-widget.component";
import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component";
import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component";
Expand Down Expand Up @@ -50,13 +52,15 @@ describe("Program page: program-scoped news and role visibility", () => {
const added = { ...news, id: 2, text: "Созданная организатором новость" };
const fetch = vi.fn(),
add = vi.fn();
const mine = vi.fn();
let data: BehaviorSubject<{ data: Program }>;

beforeEach(async () => {
vi.useFakeTimers();
data = new BehaviorSubject({ data: Program.default() });
fetch.mockReset().mockReturnValue(of(ok({ count: 1, results: [news] })));
add.mockReset().mockReturnValue(of(ok(added)));
mine.mockReset().mockReturnValue(of([]));
vi.spyOn(ProgramDetailMainService.prototype, "initScroll").mockImplementation(() => {});
await TestBed.configureTestingModule({
imports: [ProgramDetailMainComponent],
Expand Down Expand Up @@ -92,6 +96,9 @@ describe("Program page: program-scoped news and role visibility", () => {
].map(provide => ({ provide, useValue: {} })),
],
})
.overrideComponent(MyCertificateComponent, {
set: { providers: [{ provide: ProgramCertificatesUseCase, useValue: { mine } }] },
})
.overrideComponent(ProgramDetailMainComponent, {
remove: {
imports: [NewsFormComponent, NewsCardComponent, ProgramRoleWidgetComponent],
Expand Down Expand Up @@ -153,6 +160,7 @@ describe("Program page: program-scoped news and role visibility", () => {
expect(fetch).toHaveBeenCalledOnce();
expect(f.nativeElement.textContent).toContain(news.text);
expect(f.nativeElement.querySelector("app-news-form")).toBeNull();
expect(mine).toHaveBeenCalledExactlyOnceWith(12);
});
it("shows expert widget without requesting or revealing member/manager news", () => {
const f = render({ isUserExpert: true });
Expand All @@ -161,6 +169,7 @@ describe("Program page: program-scoped news and role visibility", () => {
expect(f.nativeElement.querySelector("app-program-role-widget")).toBeTruthy();
expect(f.nativeElement.querySelector("app-news-card,app-news-form")).toBeNull();
expect(fetch).not.toHaveBeenCalled();
expect(mine).not.toHaveBeenCalled();
});
it("preserves advertisement and no internal content for an unrelated user", () => {
const f = render({});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/** @format */
import { TestBed } from "@angular/core/testing";
import { of, Subject, throwError } from "rxjs";
import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case";
import { MyCertificateComponent } from "./my-certificate.component";

function setup(mine = vi.fn(() => of([] as { id: string; issuedAt: string; url: string }[]))) {
TestBed.configureTestingModule({ imports: [MyCertificateComponent] });
TestBed.overrideComponent(MyCertificateComponent, {
set: { providers: [{ provide: ProgramCertificatesUseCase, useValue: { mine } }] },
});
const fixture = TestBed.createComponent(MyCertificateComponent);
fixture.componentRef.setInput("programId", 12);
fixture.detectChanges();
return { fixture, component: fixture.componentInstance, mine };
}
describe("Participant certificate", () => {
it("shows only the own download returned by API", () => {
const row = {
id: "own",
issuedAt: "2026-10-24",
url: "https://dev.procollab.ru/programs/12/certificates/download/own/",
};
const { fixture, mine } = setup(vi.fn(() => of([row])));
fixture.detectChanges();
expect(mine).toHaveBeenCalledExactlyOnceWith(12);
expect(fixture.nativeElement.querySelector("a").href).toBe(row.url);
fixture.destroy();
});
it("hides an empty state when no certificate has been issued", () => {
const empty = setup();
empty.fixture.detectChanges();
expect(empty.fixture.nativeElement.querySelector("section")).toBeNull();
empty.fixture.destroy();
});
it("never displays a late certificate from the previously opened program", () => {
const old = new Subject<any[]>(),
current = new Subject<any[]>();
const mine = vi.fn().mockReturnValueOnce(old).mockReturnValueOnce(current);
const { fixture, component } = setup(mine);
fixture.componentRef.setInput("programId", 13);
fixture.detectChanges();
old.next([{ id: "old", url: "https://example.com/old" }]);
old.complete();
current.next([]);
current.complete();
fixture.detectChanges();
expect(component.url()).toBeNull();
expect(fixture.nativeElement.querySelector("a")).toBeNull();
fixture.destroy();
});
it("offers a retry after a failed request without displaying a stale link", () => {
const { fixture, component, mine } = setup(
vi
.fn()
.mockReturnValueOnce(throwError(() => new Error("offline")))
.mockReturnValueOnce(of([])),
);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Не удалось проверить сертификат");
component.load();
fixture.detectChanges();
expect(mine).toHaveBeenCalledTimes(2);
expect(component.error()).toBe(false);
fixture.destroy();
});
});
Loading