Răsfoiți Sursa

Улучшены валидаторы форм

Vadim 2 luni în urmă
părinte
comite
c9df4243d6

+ 1 - 1
src/modules/client/custom-fields/c-cf-validate-service.ts

@@ -7,7 +7,7 @@ import {
   Validator,
 } from "#api/v_0.1.0/types/custom-fields-types.js";
 import { z } from "zod";
-import { getValidationFunc } from "./validation-functions.js";
+import { getValidationFunc } from "./validators/validation-functions.js";
 import { ApiError } from "#exceptions/api-error.js";
 import { filesUtils } from "#utils/files-utils.js";
 import { DatabaseTransactionConnection, sql } from "slonik";

+ 0 - 266
src/modules/client/custom-fields/validation-functions.ts

@@ -1,266 +0,0 @@
-import { z } from "zod";
-import validatorjs from "validator";
-import type { Dayjs } from "#dayjs";
-import { dayjs } from "#dayjs";
-import { Validator } from "#api/v_0.1.0/types/custom-fields-types.js";
-type Validator = z.infer<typeof Validator>;
-
-// утилиты
-function validateLeastYearsAgo(date: string, years: number): boolean {
-  // 1. Проверяем, что minAge является неотрицательным числом
-  if (typeof years !== "number" || years < 0 || !Number.isInteger(years)) {
-    console.error("Invalid years: must be a non-negative integer.");
-    return false;
-  }
-
-  // 2. Парсим дату рождения.
-  // Третий аргумент `true` включает строгий режим парсинга для указанного формата.
-  // Это означает, что дата должна точно соответствовать 'YYYY-MM-DD'.
-  const birthDate: Dayjs = dayjs(date, "DD.MM.YYYY", true);
-
-  // 3. Проверяем валидность даты.
-  if (!birthDate.isValid()) {
-    console.warn(`Invalid date format or non-existent date provided: ${date}`);
-    return false;
-  }
-
-  // 4. Получаем текущую дату.
-  const today: Dayjs = dayjs();
-
-  // 5. Проверяем, не является ли дата рождения будущей датой.
-  if (birthDate.isAfter(today)) {
-    console.warn(`Birth date ${date} cannot be in the future.`);
-    return false;
-  }
-
-  const calculatedAge: number = today.diff(birthDate, "year");
-
-  // 7. Сравниваем рассчитанный возраст с минимально допустимым.
-  return calculatedAge >= years;
-}
-
-const getTypeError = (expected: "string" | "number" | "date" | "audio") => {
-  const types = {
-    string: "строкой",
-    number: "числом",
-    date: "датой",
-    audio: "аудио файлом",
-  };
-  return `Поле должно быть ${types[expected]}`;
-};
-const parseValidatorValue = (value: unknown): number | null => {
-  const num = Number(value);
-  return Number.isFinite(num) ? num : null;
-};
-
-// Декларация для поддержки webkitAudioContext в TypeScript
-declare global {
-  interface Window {
-    webkitAudioContext?: typeof AudioContext;
-  }
-}
-async function getAudioDuration(file: File): Promise<number> {
-  // Проверка на окружение браузера
-  if (
-    typeof window !== "undefined" &&
-    typeof window.document !== "undefined" &&
-    typeof URL !== "undefined" &&
-    typeof Audio !== "undefined"
-  ) {
-    return new Promise<number>((resolve, reject) => {
-      const audio = new Audio();
-      // Устанавливаем preload в 'metadata', чтобы загрузить только метаданные, а не весь файл
-      audio.preload = "metadata";
-      const objectUrl = URL.createObjectURL(file);
-
-      const cleanup = () => {
-        URL.revokeObjectURL(objectUrl);
-        // Удаляем обработчики, чтобы избежать утечек памяти и повторных вызовов
-        audio.onloadedmetadata = null;
-        audio.onerror = null;
-      };
-
-      audio.onloadedmetadata = () => {
-        cleanup();
-        if (typeof audio.duration === "number" && isFinite(audio.duration)) {
-          resolve(audio.duration);
-        } else {
-          // Иногда duration может быть Infinity, если файл не может быть корректно прочитан
-          reject(new Error("Audio duration is invalid or infinite."));
-        }
-      };
-
-      audio.onerror = (e) => {
-        cleanup();
-        let errorMessage = "Error loading audio metadata in browser.";
-        // Попытка получить более детальную информацию об ошибке
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        if (e && (e as any).target && (e as any).target.error) {
-          // eslint-disable-next-line @typescript-eslint/no-explicit-any
-          const mediaError = (e as any).target.error as MediaError;
-          switch (mediaError.code) {
-            case MediaError.MEDIA_ERR_ABORTED:
-              errorMessage += " Fetch aborted by user.";
-              break;
-            case MediaError.MEDIA_ERR_NETWORK:
-              errorMessage += " Network error.";
-              break;
-            case MediaError.MEDIA_ERR_DECODE:
-              errorMessage += " Decode error or unsupported format.";
-              break;
-            case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
-              errorMessage += " Source format not supported.";
-              break;
-            default:
-              errorMessage += " Unknown media error.";
-          }
-        } else if (e instanceof Event && typeof e.type === "string") {
-          // Общий случай ошибки события
-          errorMessage += ` Event type: ${e.type}.`;
-        }
-        reject(new Error(errorMessage));
-      };
-
-      audio.src = objectUrl;
-      // audio.load(); // Обычно не требуется, т.к. установка src запускает загрузку
-    });
-  }
-  // Проверка на окружение Node.js
-  else if (
-    typeof process !== "undefined" &&
-    process.versions &&
-    process.versions.node
-  ) {
-    try {
-      // Динамический импорт, чтобы music-metadata не пытался загрузиться в браузере
-      const { parseBuffer } = await import("music-metadata");
-
-      // File API предоставляет arrayBuffer(), который мы конвертируем в Buffer Node.js
-      const arrayBuffer = await file.arrayBuffer();
-      const buffer = Buffer.from(arrayBuffer);
-
-      // parseBuffer ожидает Buffer и опционально mime-тип и размер
-      // file.type и file.size приходят из объекта File
-      const metadata = await parseBuffer(
-        buffer,
-        { mimeType: file.type, size: file.size },
-        { duration: true },
-      );
-
-      if (metadata.format && typeof metadata.format.duration === "number") {
-        return metadata.format.duration;
-      } else {
-        throw new Error(
-          "Duration not found in audio metadata using music-metadata.",
-        );
-      }
-    } catch (error) {
-      const message = error instanceof Error ? error.message : String(error);
-      // Перебрасываем ошибку, чтобы ее можно было отловить выше
-      throw new Error(`Error processing audio in Node.js: ${message}`);
-    }
-  } else {
-    // Неподдерживаемое окружение
-    return Promise.reject(
-      new Error(
-        "Unsupported environment: Not browser or Node.js, or required APIs are missing.",
-      ),
-    );
-  }
-}
-
-//
-//
-//
-//
-export const getValidationFunc = (
-  validator: Validator,
-): ((v: unknown) => (string | true) | Promise<string | true>) | undefined => {
-  const { code, value } = validator;
-
-  switch (validator.code) {
-    case "required":
-      return (v: unknown) =>
-        v !== undefined && v !== null && v !== ""
-          ? true
-          : "Поле должно быть заполнено";
-
-    // Строковые валидаторы
-    case "maxString":
-    case "minString": {
-      const parsedValue = parseValidatorValue(value);
-      if (parsedValue === null) {
-        return () => `Некорректное значение валидатора: ${value}`;
-      }
-
-      return (v: unknown) => {
-        if (v === null) return true;
-        if (typeof v !== "string") return getTypeError("string");
-        return code === "maxString"
-          ? v.length <= parsedValue || `Максимум ${parsedValue} символов`
-          : v.length >= parsedValue || `Минимум ${parsedValue} символов`;
-      };
-    }
-
-    case "isMail":
-      return (v: unknown) => {
-        if (v === null) return true;
-        if (typeof v !== "string") return getTypeError("string");
-        return validatorjs.isEmail(v) || "Введите корректную почту";
-      };
-
-    // date
-    case "leastYearsAgo": {
-      const parsedValue = parseValidatorValue(value);
-      if (parsedValue === null) {
-        return () => `Некорректное значение валидатора: ${value}`;
-      }
-
-      return (v: unknown) => {
-        if (v === null) return true;
-        if (typeof v !== "string") return getTypeError("string");
-        return (
-          validateLeastYearsAgo(v, parsedValue) || `Минимум ${parsedValue} лет}`
-        );
-      };
-    }
-
-    // Числовые валидаторы
-    case "max":
-    case "min": {
-      const parsedValue = parseValidatorValue(value);
-      if (parsedValue === null) {
-        return () => `Некорректное значение валидатора: ${value}`;
-      }
-
-      return (v: unknown) => {
-        if (v === null) return true;
-        if (typeof v !== "number") return getTypeError("number");
-        return code === "max"
-          ? v <= parsedValue || `Не больше ${parsedValue}`
-          : v >= parsedValue || `Не меньше ${parsedValue}`;
-      };
-    }
-
-    // Музыка
-    case "audioMaxSec": {
-      const parsedValue = parseValidatorValue(value);
-      if (parsedValue === null) {
-        return () => `Некорректное значение валидатора: ${value}`;
-      }
-
-      return async (v: unknown) => {
-        if (v === null) return true;
-        if (!(v instanceof File)) return getTypeError("audio");
-        const duration = await getAudioDuration(v);
-        return (
-          duration <= parsedValue ||
-          `Превышен лимит длительности ${parsedValue} секунд`
-        );
-      };
-    }
-
-    default:
-      return () => `Неизвестный валидатор: ${code}`;
-  }
-};

+ 55 - 0
src/modules/client/custom-fields/validators/general-validators-utils.ts

@@ -0,0 +1,55 @@
+import type { Dayjs } from "dayjs";
+import dayjs from "dayjs";
+
+class GeneralValidatorUtils {
+  validateAge(date: string, years: number): boolean {
+    // 1. Проверяем, что minAge является неотрицательным числом
+    if (typeof years !== "number" || years < 0 || !Number.isInteger(years)) {
+      console.error("Invalid years: must be a non-negative integer.");
+      return false;
+    }
+
+    // 2. Парсим дату рождения.
+    // Третий аргумент `true` включает строгий режим парсинга для указанного формата.
+    // Это означает, что дата должна точно соответствовать 'YYYY-MM-DD'.
+    const birthDate: Dayjs = dayjs(date, "DD.MM.YYYY", true);
+
+    // 3. Проверяем валидность даты.
+    if (!birthDate.isValid()) {
+      console.warn(
+        `Invalid date format or non-existent date provided: ${date}`,
+      );
+      return false;
+    }
+
+    // 4. Получаем текущую дату.
+    const today: Dayjs = dayjs();
+
+    // 5. Проверяем, не является ли дата рождения будущей датой.
+    if (birthDate.isAfter(today)) {
+      console.warn(`Birth date ${date} cannot be in the future.`);
+      return false;
+    }
+
+    const calculatedAge: number = today.diff(birthDate, "year");
+
+    // 7. Сравниваем рассчитанный возраст с минимально допустимым.
+    return calculatedAge >= years;
+  }
+
+  getTypeError(expected: "string" | "number" | "date" | "audio") {
+    const types = {
+      string: "строкой",
+      number: "числом",
+      date: "датой",
+      audio: "аудио файлом",
+    };
+    return `Поле должно быть ${types[expected]}`;
+  }
+  parseValidatorValue(value: unknown): number | null {
+    const num = Number(value);
+    return Number.isFinite(num) ? num : null;
+  }
+}
+
+export const generalValidatorUtils = new GeneralValidatorUtils();

+ 49 - 0
src/modules/client/custom-fields/validators/specific-validators-utils.ts

@@ -0,0 +1,49 @@
+import { logger } from "#plugins/logger.js";
+
+// Декларация для поддержки webkitAudioContext в TypeScript
+declare global {
+  interface Window {
+    webkitAudioContext?: typeof AudioContext;
+  }
+}
+
+class SpecificValidatorUtils {
+  async getAudioDuration(file: File): Promise<number> {
+    try {
+      // Динамический импорт, чтобы music-metadata не пытался загрузиться в браузере
+      const { parseBuffer } = await import("music-metadata");
+
+      // File API предоставляет arrayBuffer(), который мы конвертируем в Buffer Node.js
+      const arrayBuffer = await file.arrayBuffer();
+      const buffer = Buffer.from(arrayBuffer);
+
+      // parseBuffer ожидает Buffer и опционально mime-тип и размер
+      // file.type и file.size приходят из объекта File
+      const metadata = await parseBuffer(
+        buffer,
+        { mimeType: file.type, size: file.size },
+        { duration: true },
+      );
+
+      if (metadata.format && typeof metadata.format.duration === "number") {
+        return metadata.format.duration;
+      } else {
+        throw new Error(
+          "Duration not found in audio metadata using music-metadata.",
+        );
+      }
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      // Перебрасываем ошибку, чтобы ее можно было отловить выше
+      throw new Error(`Error processing audio in Node.js: ${message}`);
+    }
+  }
+
+  async validateIsAdultOrChild(date: string): Promise<true | string> {
+    logger.silly(date);
+    // TODO: Реализовать
+    return true;
+  }
+}
+
+export const specificValidatorUtils = new SpecificValidatorUtils();

+ 171 - 0
src/modules/client/custom-fields/validators/validation-functions.ts

@@ -0,0 +1,171 @@
+import dayjs from "dayjs";
+import validatorjs from "validator";
+import { generalValidatorUtils } from "./general-validators-utils.js";
+import { specificValidatorUtils } from "./specific-validators-utils.js";
+type FieldTypeCode =
+  | "string"
+  | "number"
+  | "checkbox"
+  | "date"
+  | "audio"
+  | "img"
+  | "video"
+  | "pdf"
+  | "other";
+type Validator = {
+  validatorId: string;
+  code: string;
+  fieldTypeCode: FieldTypeCode;
+  name: string;
+  value: string | null;
+};
+
+//
+//
+//
+//
+export const getValidationFunc = (
+  validator: Validator,
+): ((v: unknown) => (string | true) | Promise<string | true>) | undefined => {
+  const { code, value } = validator;
+
+  switch (validator.code) {
+    case "required":
+      return (v: unknown) =>
+        v !== undefined && v !== null && v !== ""
+          ? true
+          : "Поле должно быть заполнено";
+
+    // Строковые валидаторы
+    case "maxString":
+    case "minString": {
+      const parsedValue = generalValidatorUtils.parseValidatorValue(value);
+      if (parsedValue === null) {
+        return () => `Некорректное значение валидатора: ${value}`;
+      }
+
+      return (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "string")
+          return generalValidatorUtils.getTypeError("string");
+        return code === "maxString"
+          ? v.length <= parsedValue || `Максимум ${parsedValue} символов`
+          : v.length >= parsedValue || `Минимум ${parsedValue} символов`;
+      };
+    }
+    case "isMail":
+      return (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "string")
+          return generalValidatorUtils.getTypeError("string");
+        return validatorjs.isEmail(v) || "Введите корректную почту";
+      };
+    case "isUrl":
+      return (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "string")
+          return generalValidatorUtils.getTypeError("string");
+        return validatorjs.isURL(v) || "Введите корректную ссылку";
+      };
+    case "isPhone":
+      return (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "string")
+          return generalValidatorUtils.getTypeError("string");
+        return (
+          validatorjs.isMobilePhone(v) || "Введите корректный номер телефона"
+        );
+      };
+
+    // date
+    case "minAge": {
+      const parsedValue = generalValidatorUtils.parseValidatorValue(value);
+      if (parsedValue === null) {
+        return () => `Некорректное значение валидатора: ${value}`;
+      }
+
+      return (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "string")
+          return generalValidatorUtils.getTypeError("string");
+        return (
+          generalValidatorUtils.validateAge(v, parsedValue) ||
+          `Минимальный возраст - ${parsedValue} лет`
+        );
+      };
+    }
+    case "minDate":
+    case "maxDate": {
+      const parsedValue = generalValidatorUtils.parseValidatorValue(value);
+      if (parsedValue === null) {
+        return () => `Некорректное значение валидатора: ${value}`;
+      }
+
+      return (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "string")
+          return generalValidatorUtils.getTypeError("string");
+
+        if (code === "minDate") {
+          return (
+            dayjs(v).isBefore(dayjs(parsedValue)) ||
+            `Минимальная дата - ${parsedValue}`
+          );
+        } else {
+          return (
+            dayjs(v).isAfter(dayjs(parsedValue)) ||
+            `Максимальная дата - ${parsedValue}`
+          );
+        }
+      };
+    }
+    case "isAdultOrChild": {
+      return async (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "string")
+          return generalValidatorUtils.getTypeError("string");
+        return await specificValidatorUtils.validateIsAdultOrChild(v);
+      };
+    }
+
+    // Числовые валидаторы
+    case "max":
+    case "min": {
+      const parsedValue = generalValidatorUtils.parseValidatorValue(value);
+      if (parsedValue === null) {
+        return () => `Некорректное значение валидатора: ${value}`;
+      }
+
+      return (v: unknown) => {
+        if (v === null) return true;
+        if (typeof v !== "number")
+          return generalValidatorUtils.getTypeError("number");
+        return code === "max"
+          ? v <= parsedValue || `Не больше ${parsedValue}`
+          : v >= parsedValue || `Не меньше ${parsedValue}`;
+      };
+    }
+
+    // Музыка
+    case "audioMaxSec": {
+      const parsedValue = generalValidatorUtils.parseValidatorValue(value);
+      if (parsedValue === null) {
+        return () => `Некорректное значение валидатора: ${value}`;
+      }
+
+      return async (v: unknown) => {
+        if (v === null) return true;
+        if (!(v instanceof File))
+          return generalValidatorUtils.getTypeError("audio");
+        const duration = await specificValidatorUtils.getAudioDuration(v);
+        return (
+          duration <= parsedValue ||
+          `Превышен лимит длительности ${parsedValue} секунд`
+        );
+      };
+    }
+
+    default:
+      return () => `Неизвестный валидатор: ${code}`;
+  }
+};