fix(install): блокировать переход с формы совета без заполненных данных

Кнопка «Продолжить» проверяла только число слотов совета, а не email и
обязательные поля ФЛ. Добавлена валидация на фронте и в install.interactor,
чтобы частично заполненный состав не уходил на цепь.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alex Ant
2026-07-14 13:04:43 +03:00
parent 45feef2676
commit 74dce675b9
6 changed files with 153 additions and 14 deletions
@@ -28,6 +28,39 @@ import { normalizeUserEmail } from '~/utils/normalize-user-email';
/** Минимум членов совета — как MIN_SOVIET_MEMBERS_COUNT в контракте soviet (3 prod / 1 dev). */
const MIN_SOVIET_MEMBERS_COUNT = config.min_soviet_members_count;
function councilRoleLabel(index: number, role?: string): string {
if (role === 'chairman' || index === 0) return 'председателя совета';
return `члена совета №${index + 1}`;
}
function assertSovietMemberComplete(
member: InstallInputDomainInterface['soviet'][number],
index: number,
): void {
const data = member?.individual_data;
const label = councilRoleLabel(index, member?.role);
const email = typeof data?.email === 'string' ? data.email.trim() : '';
if (!email) {
throw new BadRequestException(`Не указан email для ${label}`);
}
const requiredStringFields: Array<[keyof Cooperative.Users.IIndividualData, string]> = [
['last_name', 'фамилия'],
['first_name', 'имя'],
['full_address', 'адрес регистрации'],
['birthdate', 'дата рождения'],
['phone', 'телефон'],
];
for (const [field, humanName] of requiredStringFields) {
const value = data?.[field];
if (typeof value !== 'string' || !value.trim()) {
throw new BadRequestException(`Не заполнено поле «${humanName}» для ${label}`);
}
}
}
@Injectable()
export class InstallInteractor {
constructor(
@@ -140,6 +173,8 @@ export class InstallInteractor {
);
}
data.soviet.forEach((member, index) => assertSovietMemberComplete(member, index));
// Повтор после прерванной установки: снимаем maintenance, дальше — обычный прогон.
if (isIncompleteInstallMaintenance) {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
@@ -3,3 +3,9 @@ export {
getSovietMembersProgressHint,
getSovietContinueBlockedTooltip,
} from './minSovietMembers';
export {
isSovietMemberComplete,
countCompleteSovietMembers,
getFirstIncompleteSovietMemberIndex,
type SovietInstallMember,
} from './sovietMemberValidation';
@@ -1,23 +1,57 @@
import { env } from 'src/shared/config';
import {
countCompleteSovietMembers,
getFirstIncompleteSovietMemberIndex,
type SovietInstallMember,
} from './sovietMemberValidation';
/** Как MIN_SOVIET_MEMBERS_COUNT в контракте soviet: 1 на dev/testnet, 3 на production. */
export function getMinSovietMembersCount(): number {
return env.NODE_ENV === 'production' ? 3 : 1;
}
function councilRoleLabel(index: number, role?: SovietInstallMember['role']): string {
if (role === 'chairman' || index === 0) return 'председателя совета';
return `члена совета №${index + 1}`;
}
/** Краткая подсказка под формой: сколько указано и чего не хватает. */
export function getSovietMembersProgressHint(currentCount: number, min = getMinSovietMembersCount()): string | null {
if (min <= 1 || currentCount >= min) return null;
const missing = min - currentCount;
return `Указано ${currentCount} из ${min}. Добавьте ещё ${missing} ${pluralCouncilSlot(missing)}.`;
export function getSovietMembersProgressHint(
members: SovietInstallMember[],
min = getMinSovietMembersCount(),
): string | null {
const completeCount = countCompleteSovietMembers(members);
if (completeCount < min) {
const missing = min - completeCount;
return `Заполнены данные ${completeCount} из ${min}. Дозаполните ещё ${missing} ${pluralCouncilSlot(missing)}.`;
}
const incompleteIndex = getFirstIncompleteSovietMemberIndex(members);
if (incompleteIndex >= 0) {
return `Заполните все поля для ${councilRoleLabel(incompleteIndex, members[incompleteIndex]?.role)}.`;
}
return null;
}
/** Текст для tooltip заблокированной кнопки «Продолжить». */
export function getSovietContinueBlockedTooltip(currentCount: number, min = getMinSovietMembersCount()): string {
if (currentCount >= min) return '';
if (min === 1) return 'Заполните данные председателя';
const missing = min - currentCount;
return `Для продолжения нужно ${min} человека в составе совета (ещё ${missing} ${pluralCouncilSlot(missing)})`;
export function getSovietContinueBlockedTooltip(
members: SovietInstallMember[],
min = getMinSovietMembersCount(),
): string {
const completeCount = countCompleteSovietMembers(members);
if (completeCount < min) {
const missing = min - completeCount;
if (min === 1) return 'Заполните все данные председателя совета';
return `Для продолжения нужно ${min} человека с полными данными (ещё ${missing} ${pluralCouncilSlot(missing)})`;
}
const incompleteIndex = getFirstIncompleteSovietMemberIndex(members);
if (incompleteIndex >= 0) {
return `Заполните все поля для ${councilRoleLabel(incompleteIndex, members[incompleteIndex]?.role)}`;
}
return '';
}
function pluralCouncilSlot(count: number): string {
@@ -0,0 +1,50 @@
import emailRegex from 'email-regex';
import type { IIndividualData } from 'src/shared/lib/types/user/IUserData';
const emailValidator = emailRegex({ exact: true });
/** Член совета в мастере установки (store + API install). */
export interface SovietInstallMember {
role?: 'chairman' | 'member';
individual_data?: IIndividualData & { email?: string };
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isPhoneFilled(value: unknown): boolean {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
return trimmed !== '+7 (___) ___-__-__';
}
/** Все обязательные поля IndividualDataForm + email (как на экране «Состав совета»). */
export function isSovietMemberComplete(member: SovietInstallMember | null | undefined): boolean {
const data = member?.individual_data;
if (!data) return false;
if (!isNonEmptyString(data.email) || emailValidator.test(data.email.trim()) !== true) {
return false;
}
if (!isNonEmptyString(data.last_name) || !isNonEmptyString(data.first_name)) {
return false;
}
if (!isNonEmptyString(data.full_address) || !isNonEmptyString(data.birthdate)) {
return false;
}
if (!isPhoneFilled(data.phone)) {
return false;
}
return true;
}
export function countCompleteSovietMembers(members: SovietInstallMember[]): number {
return members.filter(isSovietMemberComplete).length;
}
export function getFirstIncompleteSovietMemberIndex(members: SovietInstallMember[]): number {
return members.findIndex((member) => !isSovietMemberComplete(member));
}
@@ -1,7 +1,7 @@
import type { Mutations, Queries } from '@coopenomics/sdk';
import { useInstallCooperativeStore } from 'src/entities/Installer/model'
import { stripLegacyBankKpp } from 'src/shared/lib/utils/stripLegacyBankKpp';
import { getMinSovietMembersCount } from '../lib';
import { getMinSovietMembersCount, isSovietMemberComplete } from '../lib';
import { api } from '../api'
export type IInstallInput = Mutations.System.InstallSystem.IInput['data']
@@ -61,6 +61,15 @@ export const useInstallCooperative = (): {
);
}
const incompleteIndex = store.soviet.findIndex((member) => !isSovietMemberComplete(member));
if (incompleteIndex >= 0) {
const roleLabel =
store.soviet[incompleteIndex]?.role === 'chairman' || incompleteIndex === 0
? 'председателя совета'
: `члена совета №${incompleteIndex + 1}`;
throw new Error(`Заполните все обязательные поля для ${roleLabel}`);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const soviet = store.soviet.map(({ id, type, ...rest }) => rest);
@@ -64,6 +64,7 @@ import {
getMinSovietMembersCount,
getSovietMembersProgressHint,
getSovietContinueBlockedTooltip,
isSovietMemberComplete,
} from '../../lib';
import { notEmpty } from 'src/shared/lib/utils';
import { validEmail } from 'src/shared/lib/utils/validEmailRule';
@@ -74,11 +75,15 @@ const minSovietMembers = getMinSovietMembersCount();
installStore.is_finish = false;
const sovietCount = computed(() => installStore.soviet.length);
const canContinue = computed(() => sovietCount.value >= minSovietMembers);
const progressHint = computed(() => getSovietMembersProgressHint(sovietCount.value, minSovietMembers));
const sovietMembers = computed(() => installStore.soviet);
const canContinue = computed(
() =>
sovietMembers.value.length >= minSovietMembers &&
sovietMembers.value.every(isSovietMemberComplete),
);
const progressHint = computed(() => getSovietMembersProgressHint(sovietMembers.value, minSovietMembers));
const continueBlockedTooltip = computed(() =>
getSovietContinueBlockedTooltip(sovietCount.value, minSovietMembers),
getSovietContinueBlockedTooltip(sovietMembers.value, minSovietMembers),
);
const add = () => {