Przeglądaj źródła

Добавлена поддержка файлов в формах

Vadim 3 miesięcy temu
rodzic
commit
6772c1dee7

+ 174 - 3
package-lock.json

@@ -18,10 +18,12 @@
         "jsonwebtoken": "^9.0.2",
         "log4js": "^6.9.1",
         "multer": "^1.4.5-lts.1",
+        "music-metadata": "^11.2.3",
         "nodemailer": "^6.10.0",
         "pg": "^8.13.3",
         "slonik": "^37.6.0",
         "uuid": "^10.0.0",
+        "validator": "^13.15.0",
         "winston": "^3.17.0",
         "zod": "^3.24.2"
       },
@@ -36,6 +38,7 @@
         "@types/nodemailer": "^6.4.17",
         "@types/pg": "^8.11.11",
         "@types/uuid": "^10.0.0",
+        "@types/validator": "^13.15.1",
         "@typescript-eslint/eslint-plugin": "^6.21.0",
         "@typescript-eslint/parser": "^6.21.0",
         "eslint": "^8.57.1",
@@ -311,6 +314,28 @@
         "node": ">=14"
       }
     },
+    "node_modules/@tokenizer/inflate": {
+      "version": "0.2.7",
+      "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
+      "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "fflate": "^0.8.2",
+        "token-types": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
+    "node_modules/@tokenizer/token": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+      "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
+    },
     "node_modules/@types/bcrypt": {
       "version": "5.0.2",
       "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
@@ -556,6 +581,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/validator": {
+      "version": "13.15.1",
+      "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.1.tgz",
+      "integrity": "sha512-9gG6ogYcoI2mCMLdcO0NYI0AYrbxIjv0MDmy/5Ywo6CpWWrqYayc+mmgxRsCgtcGJm9BSbXkMsmxGah1iGHAAQ==",
+      "dev": true
+    },
     "node_modules/@typescript-eslint/eslint-plugin": {
       "version": "6.21.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@@ -1380,9 +1411,9 @@
       "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
     },
     "node_modules/debug": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
-      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+      "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
       "dependencies": {
         "ms": "^2.1.3"
       },
@@ -1901,6 +1932,11 @@
       "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
       "license": "MIT"
     },
+    "node_modules/fflate": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
+    },
     "node_modules/file-entry-cache": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -1913,6 +1949,23 @@
         "node": "^10.12.0 || >=12.0.0"
       }
     },
+    "node_modules/file-type": {
+      "version": "20.5.0",
+      "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.5.0.tgz",
+      "integrity": "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==",
+      "dependencies": {
+        "@tokenizer/inflate": "^0.2.6",
+        "strtok3": "^10.2.0",
+        "token-types": "^6.0.0",
+        "uint8array-extras": "^1.4.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+      }
+    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2434,6 +2487,25 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
     "node_modules/ignore": {
       "version": "5.3.0",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz",
@@ -3002,6 +3074,42 @@
         "node": ">= 6.0.0"
       }
     },
+    "node_modules/music-metadata": {
+      "version": "11.2.3",
+      "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.2.3.tgz",
+      "integrity": "sha512-ReVxFoO12kaRiaNmqxkAdytul1Ntl2ersdIyw/CqWPysvOFpUrr19s8uOHEA4xjK69ETmpP71KezXWEE7r5Myg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/Borewit"
+        },
+        {
+          "type": "buymeacoffee",
+          "url": "https://buymeacoffee.com/borewit"
+        }
+      ],
+      "dependencies": {
+        "@tokenizer/token": "^0.3.0",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.1",
+        "file-type": "^20.5.0",
+        "media-typer": "^1.1.0",
+        "strtok3": "^10.2.2",
+        "token-types": "^6.0.0",
+        "uint8array-extras": "^1.4.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/music-metadata/node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -3295,6 +3403,18 @@
         "node": ">=8"
       }
     },
+    "node_modules/peek-readable": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-7.0.0.tgz",
+      "integrity": "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
     "node_modules/pg": {
       "version": "8.13.3",
       "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz",
@@ -4206,6 +4326,22 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/strtok3": {
+      "version": "10.2.2",
+      "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.2.2.tgz",
+      "integrity": "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg==",
+      "dependencies": {
+        "@tokenizer/token": "^0.3.0",
+        "peek-readable": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
     "node_modules/supports-color": {
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -4285,6 +4421,22 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/token-types": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz",
+      "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==",
+      "dependencies": {
+        "@tokenizer/token": "^0.3.0",
+        "ieee754": "^1.2.1"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
     "node_modules/touch": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
@@ -4395,6 +4547,17 @@
         "node": ">=14.17"
       }
     },
+    "node_modules/uint8array-extras": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz",
+      "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/undefsafe": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -4457,6 +4620,14 @@
         "uuid": "dist/bin/uuid"
       }
     },
+    "node_modules/validator": {
+      "version": "13.15.0",
+      "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz",
+      "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/vary": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

+ 3 - 0
package.json

@@ -37,10 +37,12 @@
     "jsonwebtoken": "^9.0.2",
     "log4js": "^6.9.1",
     "multer": "^1.4.5-lts.1",
+    "music-metadata": "^11.2.3",
     "nodemailer": "^6.10.0",
     "pg": "^8.13.3",
     "slonik": "^37.6.0",
     "uuid": "^10.0.0",
+    "validator": "^13.15.0",
     "winston": "^3.17.0",
     "zod": "^3.24.2"
   },
@@ -55,6 +57,7 @@
     "@types/nodemailer": "^6.4.17",
     "@types/pg": "^8.11.11",
     "@types/uuid": "^10.0.0",
+    "@types/validator": "^13.15.1",
     "@typescript-eslint/eslint-plugin": "^6.21.0",
     "@typescript-eslint/parser": "^6.21.0",
     "eslint": "^8.57.1",

+ 2 - 1
src/api/current-api.ts

@@ -1 +1,2 @@
-export { api } from './v_0.1.0/api.js';
+export { api } from "./v_0.1.0/api.js";
+export { apiTypes } from "./v_0.1.0/api.js";

+ 52 - 11
src/api/v_0.1.0/client/client-pe-api.ts

@@ -1,7 +1,8 @@
 import {
   CustomFieldWithUserCopyValue,
   CustomFieldWithValue,
-} from "../types/pe-types.js";
+  InputFieldValue,
+} from "../types/custom-fields-types.js";
 import { z } from "zod";
 
 class ClientPartEntitiesApi {
@@ -29,7 +30,9 @@ class ClientPartEntitiesApi {
         code: z.string(),
         name: z.string(),
         fields: z.array(
-          CustomFieldWithUserCopyValue.extend({ isCopyUserValue: z.boolean() }),
+          CustomFieldWithUserCopyValue.extend({
+            peFfId: z.string(),
+          }),
         ),
       }),
     }),
@@ -120,16 +123,20 @@ class ClientPartEntitiesApi {
   };
 
   POST_PartEntity = {
-    req: z.object({
-      peTypeId: z.string().uuid(),
-      name: z.string(),
-      form: z.array(
-        z.object({
-          peFfId: z.string().uuid(),
-          value: z.string().nullable(),
+    req: {
+      formData: {
+        body: z.object({
+          peTypeCode: z.string(),
+          name: z.string(),
+          fields: z.array(
+            z.object({
+              peFfId: z.string().uuid(),
+              value: InputFieldValue,
+            }),
+          ),
         }),
-      ),
-    }),
+      },
+    },
     res: z.object({
       code: z.enum(["success"]),
       peId: z.string().uuid(),
@@ -212,6 +219,40 @@ class ClientPartEntitiesApi {
       }),
     ]),
   };
+
+  GET_MyPesForActivity = {
+    res: z.object({
+      code: z.enum(["success"]),
+      pes: z.array(
+        z.object({
+          peId: z.string().uuid(),
+          peTypeId: z.string().uuid(),
+          peTypeCode: z.string(),
+          peTypeName: z.string(),
+          eventInstId: z.string().uuid(),
+          ownerId: z.string().uuid(),
+          name: z.string(),
+          members: z.array(
+            z.object({
+              peMemberId: z.string().uuid(),
+              userId: z.string().uuid(),
+              email: z.string().email(),
+              fields: z.array(
+                CustomFieldWithValue.extend({
+                  userEfId: z.string().uuid(),
+                }),
+              ),
+            }),
+          ),
+          fields: z.array(
+            CustomFieldWithValue.extend({
+              peFfId: z.string().uuid(),
+            }),
+          ),
+        }),
+      ),
+    }),
+  };
 }
 
 export const clientPartEntitiesApi = new ClientPartEntitiesApi();

+ 22 - 14
src/api/v_0.1.0/client/client-users-api.ts

@@ -1,28 +1,36 @@
-import { CustomFieldWithValidators } from "../types/pe-types.js";
+import {
+  CustomFieldWithValidatorsAndValue,
+  InputFieldValue,
+} from "../types/custom-fields-types.js";
 import { z } from "zod";
 
 class ClientUsersApi {
   GET_UserEventData = {
     res: z.object({
       code: z.enum(["success"]),
-      userData: z.array(
-        CustomFieldWithValidators.extend({
-          userEfId: z.string().uuid(),
-          value: z.string().nullable(),
-        }),
-      ),
+      userData: z.object({
+        fields: z.array(
+          CustomFieldWithValidatorsAndValue.extend({
+            userEfId: z.string().uuid(),
+          }),
+        ),
+      }),
     }),
   };
 
   PATCH_UserEventData = {
-    req: z.object({
-      userData: z.array(
-        z.object({
-          userEfId: z.string().uuid(),
-          value: z.string().nullable(),
+    req: {
+      formData: {
+        body: z.object({
+          fields: z.array(
+            z.object({
+              userEfId: z.string().uuid(),
+              value: InputFieldValue,
+            }),
+          ),
         }),
-      ),
-    }),
+      },
+    },
     res: z.object({
       code: z.enum(["success"]),
     }),

+ 66 - 0
src/api/v_0.1.0/types/custom-fields-types.ts

@@ -0,0 +1,66 @@
+import { z } from "zod";
+
+export const FieldTypeCode = z.enum([
+  "string",
+  "number",
+  "checkbox",
+  "audio",
+  "date",
+]);
+export const Validator = z.object({
+  validatorId: z.string().uuid(),
+  code: z.string(),
+  fieldTypeCode: FieldTypeCode,
+  name: z.string(),
+  value: z.string().nullable(),
+});
+
+export const CustomField = z.object({
+  fieldDefinitionId: z.string().uuid(),
+  code: z.string().nullable(),
+  fieldTypeCode: FieldTypeCode,
+  title: z.string(),
+  mask: z.string().nullable(),
+  options: z.array(z.string()).nullable(),
+});
+
+export const CustomFieldWithValidators = CustomField.extend({
+  validators: z.array(Validator),
+});
+
+export const SavedFieldValue = z.union([
+  z.string(),
+  z.number(),
+  z.boolean(),
+  z.object({
+    originalName: z.string(),
+    fileName: z.string(),
+    type: z.enum(["audio", "img", "video", "pdf", "other"]),
+  }),
+  z.null(),
+]);
+
+export const InputFieldValue = z.union([
+  z.string(),
+  z.number(),
+  z.boolean(),
+  z.object({
+    fileName: z.string(),
+    type: z.enum(["audio", "img", "video", "pdf", "other"]),
+  }),
+  z.null(),
+]);
+
+export const CustomFieldWithValidatorsAndValue =
+  CustomFieldWithValidators.extend({
+    value: SavedFieldValue,
+  });
+
+export const CustomFieldWithValue = CustomField.extend({
+  value: SavedFieldValue,
+});
+
+export const CustomFieldWithUserCopyValue = CustomFieldWithValidators.extend({
+  userCopyValue: z.string().nullable(),
+  isCopyUserValue: z.boolean(),
+});

+ 1 - 41
src/api/v_0.1.0/types/pe-types.ts

@@ -1,46 +1,6 @@
 import { z } from "zod";
 
-export const FieldTypeCode = z.enum([
-  "string",
-  "number",
-  "checkbox",
-  "audio",
-  "date",
-]);
-export const Validator = z.object({
-  validatorId: z.string().uuid(),
-  code: z.string(),
-  fieldTypeCode: FieldTypeCode,
-  name: z.string(),
-  value: z.string().nullable(),
-});
-
-export const CustomField = z.object({
-  fieldDefinitionId: z.string().uuid(),
-  code: z.string().nullable(),
-  fieldTypeCode: FieldTypeCode,
-  title: z.string(),
-  mask: z.string().nullable(),
-  options: z.array(z.string()).nullable(),
-});
-
-export const CustomFieldWithValidators = CustomField.extend({
-  validators: z.array(Validator),
-});
-
-export const CustomFieldWithValidatorsAndValue =
-  CustomFieldWithValidators.extend({
-    value: z.string().nullable(),
-  });
-
-export const CustomFieldWithValue = CustomField.extend({
-  value: z.string().nullable(),
-});
-
-export const CustomFieldWithUserCopyValue = CustomFieldWithValidators.extend({
-  peFfId: z.string(),
-  userCopyValue: z.string().nullable(),
-});
+import { CustomFieldWithValidators } from "./custom-fields-types.js";
 
 export const PeTypeWithFields = z.object({
   peTypeId: z.string(),

+ 3 - 0
src/main.ts

@@ -28,6 +28,9 @@ import path from "path";
 const __dirname = path.resolve();
 app.use("/files", express.static(path.join(__dirname, "files")));
 
+import { filesUtils } from "#utils/files-utils.js";
+filesUtils.createFilesDir();
+
 // лог всех запросов
 app.use((req, res, next) => {
   logger.debug(`Запрос ${req.url}`);

+ 147 - 86
src/modules/client/activities/participant-entities/c-pe-controller.ts

@@ -13,10 +13,11 @@ import { RouterUtils } from "#utils/router-utils.js";
 
 import { Request, Response } from "express";
 import sessionService from "#modules/users/auth/services/session-service.js";
-import { CustomFieldWithUserCopyValue } from "#api/v_0.1.0/types/pe-types.js";
+import { CustomFieldWithValue } from "#api/v_0.1.0/types/custom-fields-types.js";
 import { ApiError } from "#exceptions/api-error.js";
 import { v4, v7 } from "uuid";
 import { cPeService } from "./c-pe-service.js";
+import { cCustomFieldsValidateService } from "#modules/client/custom-fields/c-cf-validate-service.js";
 
 class ClientPeController {
   async getEventPeTypes(
@@ -60,69 +61,12 @@ class ClientPeController {
 
     const eventId = event.eventId;
     const userId = user.userId;
-    const peType = await selPool.maybeOne(sql.type(
-      z.object({
-        peTypeId: DbSchema.act.peTypes.peTypeId,
-        code: DbSchema.act.peTypes.code,
-        name: DbSchema.act.peTypes.name,
-        eventInstId: DbSchema.act.peTypes.eventInstId,
-        fields: z.array(
-          CustomFieldWithUserCopyValue.extend({ isCopyUserValue: z.boolean() }),
-        ),
-      }),
-    )`
-      select
-        pt.pe_type_id as "peTypeId",
-        pt.code,
-        pt.name,
-        pt.event_inst_id as "eventInstId",
-        coalesce(jsonb_agg(jsonb_build_object(
-        'fieldDefinitionId',
-        cfwv.field_definition_id,
-        'peFfId',
-        pff.pe_ff_id,
-        'isCopyUserValue',
-        pff.is_copy_user_value,
-        'code',
-        cfwv.code,
-        'userCopyValue',
-        ufwv.value,
-        'fieldTypeCode',
-        cfwv.field_type_code,
-        'title',
-        COALESCE(pff.field_title_override, cfwv.title),
-        'mask',
-        cfwv.mask,
-        'options',
-        cfwv.options,
-        'validators',
-        cfwv.validators)) filter (
-      where
-        cfwv.field_definition_id is not null),
-        '[]'::jsonb) as fields
-      from
-        act.pe_types pt
-        --	необходимые поля
-      left join act.pe_form_fields pff on
-        pff.pe_type_id = pt.pe_type_id
-        -- значение из профиля юзера
-      left join ev.user_fields_with_values ufwv on
-        pff.field_definition_id = ufwv.field_definition_id
-        and ufwv.user_id = ${userId}
-        and ufwv.event_id = ${eventId}
-        and ufwv.value is not null
-        -- только если нужно копировать
-        and pff.is_copy_user_value = true
-      left join cf.custom_fields_with_validators cfwv on
-        pff.field_definition_id = cfwv.field_definition_id
-      where 
-        pt.code = ${peTypeCode}
-      group by
-        pt.pe_type_id,
-        pt.code,
-        pt.name,
-        pt.event_inst_id
-    `);
+
+    const peType = await cPeService.getPeTypeWithFieldsAndUserCopyValues(
+      userId,
+      peTypeCode,
+      eventId,
+    );
 
     if (!peType)
       throw ApiError.BadRequest(
@@ -139,33 +83,70 @@ class ClientPeController {
   async createPe(req: Request, res: Response) {
     const event = await sessionService.getCurrentEventFromReq(req);
     const user = sessionService.getUserFromReq(req);
-    const { form, peTypeId, name } = api.client.pe.POST_PartEntity.req.parse(
-      req.body,
-    );
+    const { fields, peTypeCode, name } =
+      api.client.pe.POST_PartEntity.req.formData.body.parse(
+        JSON.parse(req.body.body),
+      );
 
-    await updPool.transaction(async (t) => {
-      const peId = v7();
-      await t.query(sql.unsafe`
-        insert into act.part_entities 
-          (pe_id, pe_type_id, event_inst_id, owner_id, name) 
-        values 
-          (${peId}, ${peTypeId}, ${event.eventInstId}, ${user.userId}, ${name})
-      `);
+    const files = req.files;
+
+    const peType = await cPeService.getPeTypeWithFields(peTypeCode);
+
+    if (!peType)
+      throw ApiError.BadRequest(
+        "peTypeNotFound",
+        "Тип сущности участия не найден",
+      );
+
+    const refFields = peType.fields.map((f) => ({
+      ...f,
+      idKey: "peFfId",
+    }));
+
+    // валидация
+    const validationResult =
+      await cCustomFieldsValidateService.processAndValidateFields({
+        inputFields: fields,
+        referenceFields: refFields,
+        files,
+        idKey: "peFfId",
+        addOldValue: false,
+      });
 
-      for (const field of form) {
-        await t.query(sql.unsafe`
-        insert into act.pe_field_values 
-          (pe_ff_id, pe_id, value)
+    if (!validationResult.isValid)
+      throw ApiError.BadRequest(
+        "fieldsValidationFailed",
+        JSON.stringify(validationResult.messages),
+      );
+
+    const validatedFields = validationResult.checkedfields;
+
+    //
+    //
+    // вставляем в базу и сохраняем файлы
+    const peId = v7();
+    await updPool.transaction(async (tr) => {
+      await tr.query(sql.unsafe`
+        insert into act.part_entities
+          (pe_id, pe_type_id, event_inst_id, owner_id, name)
         values
-          (${field.peFfId}, ${peId}, ${field.value})
+          (${peId}, ${peType.peTypeId}, ${event.eventInstId}, ${user.userId}, ${name})
       `);
-      }
 
-      RouterUtils.validAndSendResponse(api.client.pe.POST_PartEntity.res, res, {
-        code: "success",
-        peId,
+      await cCustomFieldsValidateService.saveCustomFieldValuesInTransaction({
+        tr,
+        parentId: peId,
+        action: "peCreate",
+        inputFields: validatedFields,
+        files,
+        isDeleteBefore: false,
       });
     });
+
+    RouterUtils.validAndSendResponse(api.client.pe.POST_PartEntity.res, res, {
+      code: "success",
+      peId,
+    });
   }
 
   async getMyPes(req: Request, res: Response) {
@@ -256,7 +237,7 @@ class ClientPeController {
 
     // валделец
     if (isOwner) {
-      const pe = await cPeService.getPeWithFieldsAndValues(peId);
+      const pe = await cPeService.getPeWithValues(peId);
       const members = await cPeService.getMembers(peId);
       const invites = await cPeService.getInvites(peId);
 
@@ -408,7 +389,7 @@ class ClientPeController {
       return;
     }
 
-    updPool.transaction(async (t) => {
+    await updPool.transaction(async (t) => {
       t.query(sql.unsafe`
       insert into act.pe_members 
         (pe_member_id, pe_id, user_id)
@@ -430,6 +411,86 @@ class ClientPeController {
       peId: invite.peId,
     });
   }
+
+  async getMyPesForActivity(req: Request, res: Response) {
+    const user = sessionService.getUserFromReq(req);
+
+    const event = await sessionService.getCurrentEventFromReq(req);
+
+    const pes = await selPool.any(sql.type(
+      z.object({
+        peId: DbSchema.act.partEntities.peId,
+        peTypeId: DbSchema.act.partEntities.peTypeId,
+        peTypeCode: DbSchema.act.peTypes.code,
+        peTypeName: DbSchema.act.peTypes.name,
+        eventInstId: DbSchema.act.partEntities.eventInstId,
+        ownerId: DbSchema.act.partEntities.ownerId,
+        name: DbSchema.act.partEntities.name,
+        members: z.array(
+          z.object({
+            peMemberId: DbSchema.act.peMembers.peMemberId,
+            userId: DbSchema.usr.users.userId,
+            email: DbSchema.usr.users.email,
+            fields: z.array(
+              CustomFieldWithValue.extend({
+                userEfId: z.string().uuid(),
+              }),
+            ),
+          }),
+        ),
+        fields: z.array(
+          CustomFieldWithValue.extend({
+            peFfId: z.string().uuid(),
+          }),
+        ),
+      }),
+    )`
+      select
+        pe.pe_id "peId",
+        pe.event_inst_id "eventInstId",
+        pe.owner_id "ownerId",
+        pe.pe_type_id "peTypeId",
+        pt.code "peTypeCode",
+        pt."name" "peTypeName",
+        pe."name",
+        coalesce(m.members, '[]'::jsonb) members,
+        v.fields
+      from
+        act.part_entities pe
+      join act.pe_types pt on
+        pt.pe_type_id = pe.pe_type_id
+        -- members
+      left join lateral (
+        select
+          jsonb_agg(jsonb_build_object(
+              'peMemberId', m.pe_member_id,
+              'userId', m.user_id,
+              'email', m.email,
+              'fields', m.fields
+          )) as members
+        from
+          act.pe_members_with_fields_and_values m
+        where
+          m.pe_id = pe.pe_id
+      ) m on
+        true
+        -- fields
+      left join act.pe_with_fields_and_values v on
+        v.pe_id = pe.pe_id
+      where
+        pe.event_inst_id = ${event.eventInstId}
+        and pe.owner_id = ${user.userId}
+    `);
+
+    RouterUtils.validAndSendResponse(
+      api.client.pe.GET_MyPesForActivity.res,
+      res,
+      {
+        code: "success",
+        pes: [...pes],
+      },
+    );
+  }
 }
 
 export const clientPeController = new ClientPeController();

+ 11 - 1
src/modules/client/activities/participant-entities/c-pe-router.ts

@@ -2,6 +2,7 @@ import { RouterUtils } from "#utils/router-utils.js";
 
 import express from "express";
 import { clientPeController } from "./c-pe-controller.js";
+import { upload } from "#utils/files-utils.js";
 const router = express.Router();
 export default router;
 
@@ -15,10 +16,19 @@ router.get(
   RouterUtils.asyncHandler(clientPeController.getPeTypeForCreate),
 );
 
-router.post("/create/", RouterUtils.asyncHandler(clientPeController.createPe));
+router.post(
+  "/create/",
+  upload.any(),
+  RouterUtils.asyncHandler(clientPeController.createPe),
+);
 
 router.get("/myPes/", RouterUtils.asyncHandler(clientPeController.getMyPes));
 
+router.get(
+  "/myPesForActivity",
+  RouterUtils.asyncHandler(clientPeController.getMyPesForActivity),
+);
+
 router.get("/:peId", RouterUtils.asyncHandler(clientPeController.getPe));
 
 // router.get("/:peId/members", RouterUtils.asyncHandler(clientPeController.getMembers));

+ 132 - 2
src/modules/client/activities/participant-entities/c-pe-service.ts

@@ -1,4 +1,8 @@
-import { CustomFieldWithValue } from "#api/v_0.1.0/types/pe-types.js";
+import {
+  CustomFieldWithUserCopyValue,
+  CustomFieldWithValidators,
+  CustomFieldWithValue,
+} from "#api/v_0.1.0/types/custom-fields-types.js";
 import { DbSchema } from "#db/db-schema.js";
 import { selPool } from "#db/db.js";
 import { sql } from "slonik";
@@ -17,7 +21,7 @@ class CPeService {
         `);
   }
 
-  async getPeWithFieldsAndValues(peId: string) {
+  async getPeWithValues(peId: string) {
     return await selPool.maybeOne(sql.type(
       z.object({
         peId: z.string().uuid(),
@@ -161,6 +165,132 @@ class CPeService {
         pe_invite_uuid = ${peInviteUuid}
     `);
   }
+
+  async getPeTypeWithFieldsAndUserCopyValues(
+    userId: string,
+    peTypeCode: string,
+    eventId: string,
+  ) {
+    return await selPool.maybeOne(sql.type(
+      z.object({
+        peTypeId: DbSchema.act.peTypes.peTypeId,
+        code: DbSchema.act.peTypes.code,
+        name: DbSchema.act.peTypes.name,
+        eventInstId: DbSchema.act.peTypes.eventInstId,
+        fields: z.array(
+          CustomFieldWithUserCopyValue.extend({ peFfId: z.string() }),
+        ),
+      }),
+    )`
+          select
+            pt.pe_type_id as "peTypeId",
+            pt.code,
+            pt.name,
+            pt.event_inst_id as "eventInstId",
+            coalesce(jsonb_agg(jsonb_build_object(
+                'fieldDefinitionId',
+                cfwv.field_definition_id,
+                'peFfId',
+                pff.pe_ff_id,
+                'isCopyUserValue',
+                pff.is_copy_user_value,
+                'code',
+                cfwv.code,
+                'userCopyValue',
+                ufwv.value,
+                'fieldTypeCode',
+                cfwv.field_type_code,
+                'title',
+                COALESCE(pff.field_title_override, cfwv.title),
+                'mask',
+                cfwv.mask,
+                'options',
+                cfwv.options,
+                'validators',
+                cfwv.validators
+            )) filter (
+          where
+            cfwv.field_definition_id is not null),
+            '[]'::jsonb) as fields
+          from
+            act.pe_types pt
+            --	необходимые поля
+          left join act.pe_form_fields pff on
+            pff.pe_type_id = pt.pe_type_id
+            -- значение из профиля юзера
+          left join ev.user_fields_with_values ufwv on
+            pff.field_definition_id = ufwv.field_definition_id
+            and ufwv.user_id = ${userId}
+            and ufwv.event_id = ${eventId}
+            and ufwv.value is not null
+            -- только если нужно копировать
+            and pff.is_copy_user_value = true
+          left join cf.custom_fields_with_validators cfwv on
+            pff.field_definition_id = cfwv.field_definition_id
+          where 
+            pt.code = ${peTypeCode}
+          group by
+            pt.pe_type_id,
+            pt.code,
+            pt.name,
+            pt.event_inst_id
+        `);
+  }
+
+  async getPeTypeWithFields(peTypeCode: string) {
+    return await selPool.maybeOne(sql.type(
+      z.object({
+        peTypeId: DbSchema.act.peTypes.peTypeId,
+        code: DbSchema.act.peTypes.code,
+        name: DbSchema.act.peTypes.name,
+        eventInstId: DbSchema.act.peTypes.eventInstId,
+        fields: z.array(
+          CustomFieldWithValidators.extend({ peFfId: z.string() }),
+        ),
+      }),
+    )`
+          select
+            pt.pe_type_id as "peTypeId",
+            pt.code,
+            pt.name,
+            pt.event_inst_id as "eventInstId",
+            coalesce(jsonb_agg(jsonb_build_object(
+              'fieldDefinitionId',
+              cfwv.field_definition_id,
+              'peFfId',
+              pff.pe_ff_id,
+              'code',
+              cfwv.code,
+              'fieldTypeCode',
+              cfwv.field_type_code,
+              'title',
+              COALESCE(pff.field_title_override, cfwv.title),
+              'mask',
+              cfwv.mask,
+              'options',
+              cfwv.options,
+              'validators',
+              cfwv.validators
+            )) filter (
+          where
+            cfwv.field_definition_id is not null),
+            '[]'::jsonb) as fields
+          from
+            act.pe_types pt
+            --	необходимые поля
+          left join act.pe_form_fields pff on
+            pff.pe_type_id = pt.pe_type_id 
+          left join cf.custom_fields_with_validators cfwv on
+            pff.field_definition_id = cfwv.field_definition_id
+          where 
+            pt.code = ${peTypeCode}
+          group by
+            pt.pe_type_id,
+            pt.code,
+            pt.name,
+            pt.event_inst_id
+        `);
+  }
 }
 
 export const cPeService = new CPeService();

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

@@ -0,0 +1,402 @@
+import {
+  CustomFieldWithValidators,
+  CustomFieldWithValidatorsAndValue,
+  FieldTypeCode,
+  InputFieldValue,
+  SavedFieldValue,
+  Validator,
+} from "#api/v_0.1.0/types/custom-fields-types.js";
+import { z } from "zod";
+import { getValidationFunc } from "./validation-functions.js";
+import { ApiError } from "#exceptions/api-error.js";
+import { filesUtils } from "#utils/files-utils.js";
+import { DatabaseTransactionConnection, sql } from "slonik";
+import { logger } from "#plugins/logger.js";
+
+type CustomFieldInput = {
+  value: z.infer<typeof InputFieldValue>;
+} & (
+  | { arffId: string; peFfId?: never; userEfId?: never }
+  | { peFfId: string; arffId?: never; userEfId?: never }
+  | { userEfId: string; arffId?: never; peFfId?: never }
+);
+
+type MulterFiles =
+  | {
+      [fieldname: string]: Express.Multer.File[];
+    }
+  | Express.Multer.File[]
+  | undefined;
+
+const fileTypeCodes: z.infer<typeof FieldTypeCode>[] = ["audio"];
+
+class CCustomFieldsValidateService {
+  async validateFields(
+    fields: {
+      value: unknown;
+      fieldDefinitionId: string;
+      fieldType: z.infer<typeof FieldTypeCode>;
+    }[],
+    refFields: z.infer<typeof CustomFieldWithValidators>[],
+  ) {
+    let isValid = true;
+    const messages: string[] = [];
+
+    for (const field of fields) {
+      const refField = refFields.find(
+        (f) => f.fieldDefinitionId === field.fieldDefinitionId,
+      );
+      if (!refField) {
+        isValid = false;
+        messages.push(
+          `Поле fieldDefinitionId:"${field.fieldDefinitionId}" не заполнено`,
+        );
+        continue;
+      }
+
+      for (const validator of refField.validators) {
+        const result = await this.validateField(field.value, validator);
+        if (!result.isValid) {
+          isValid = false;
+          messages.push(...result.messages);
+        }
+      }
+    }
+
+    return {
+      isValid,
+      messages,
+    };
+  }
+
+  async validateField(value: unknown, validator: z.infer<typeof Validator>) {
+    const validateFunc = getValidationFunc(validator);
+    if (!validateFunc) {
+      return {
+        isValid: true,
+        messages: [],
+      };
+    }
+    const result = await validateFunc(value);
+    return {
+      isValid: result === true,
+      messages: result === true ? [] : [result],
+    };
+  }
+
+  async createFilesMap(files: MulterFiles) {
+    return new Map(
+      (() => {
+        if (!files) return [];
+        if (Array.isArray(files)) return files.map((f) => [f.fieldname, f]);
+        return Object.entries(files).flatMap(([fieldname, files]) =>
+          files.map((file) => [fieldname, file]),
+        );
+      })(),
+    );
+  }
+  //
+  //
+  //
+
+  async processAndValidateFields({
+    inputFields,
+    referenceFields,
+    files,
+    idKey,
+    addOldValue,
+  }: {
+    inputFields: CustomFieldInput[];
+    referenceFields: (z.infer<typeof CustomFieldWithValidators> & {
+      idKey: string;
+    })[];
+    files: MulterFiles;
+    idKey: "arffId" | "peFfId" | "userEfId"; // Ключ для идентификации поля в объектах и файлах
+    addOldValue: false;
+  }): Promise<{
+    isValid: boolean;
+    messages: string[];
+    checkedfields: (CustomFieldInput & {
+      fieldType: z.infer<typeof FieldTypeCode>;
+    })[];
+  }>;
+  async processAndValidateFields({
+    inputFields,
+    referenceFields,
+    files,
+    idKey,
+    addOldValue,
+  }: {
+    inputFields: CustomFieldInput[];
+    referenceFields: (z.infer<typeof CustomFieldWithValidatorsAndValue> & {
+      idKey: string;
+    })[];
+    files: MulterFiles;
+    idKey: "arffId" | "peFfId" | "userEfId"; // Ключ для идентификации поля в объектах и файлах
+    addOldValue: true;
+  }): Promise<{
+    isValid: boolean;
+    messages: string[];
+    checkedfields: (CustomFieldInput & {
+      fieldType: z.infer<typeof FieldTypeCode>;
+      oldValue: z.infer<typeof InputFieldValue>;
+    })[];
+  }>;
+  async processAndValidateFields({
+    inputFields,
+    referenceFields,
+    files,
+    idKey,
+    addOldValue,
+  }: {
+    inputFields: CustomFieldInput[];
+    referenceFields: (
+      | (z.infer<typeof CustomFieldWithValidators> & {
+          idKey: string;
+        })
+      | (z.infer<typeof CustomFieldWithValidatorsAndValue> & {
+          idKey: string;
+        })
+    )[];
+    files: MulterFiles;
+    idKey: "arffId" | "peFfId" | "userEfId"; // Ключ для идентификации поля в объектах и файлах
+    addOldValue: boolean;
+  }): Promise<{
+    isValid: boolean;
+    messages: string[];
+    checkedfields: (
+      | (CustomFieldInput & {
+          fieldType: z.infer<typeof FieldTypeCode>;
+        })
+      | (CustomFieldInput & {
+          fieldType: z.infer<typeof FieldTypeCode>;
+          oldValue: z.infer<typeof InputFieldValue>;
+        })
+    )[];
+  }> {
+    // Проверка, что все нужные поля есть
+    const checkedfields: (
+      | (CustomFieldInput & {
+          fieldType: z.infer<typeof FieldTypeCode>;
+        })
+      | (CustomFieldInput & {
+          fieldType: z.infer<typeof FieldTypeCode>;
+          oldValue: z.infer<typeof InputFieldValue>;
+        })
+    )[] = [];
+
+    for (const refField of referenceFields) {
+      const field = inputFields.find((ff) => ff[idKey] === refField[idKey]);
+      if (!field) {
+        throw ApiError.BadRequest(
+          "fieldNotFound",
+          `Поле ${refField.fieldDefinitionId} (id: ${refField[idKey]}) не найдено`,
+        );
+      }
+      checkedfields.push({
+        ...field,
+        fieldType: refField.fieldTypeCode,
+        oldValue:
+          addOldValue && "value" in refField ? refField.value : undefined,
+      });
+    }
+
+    // Подготовка fieldsToCheck
+    const filesMap = await this.createFilesMap(files);
+
+    const fieldsToCheck = checkedfields.map((f) => {
+      const refField = referenceFields.find((rf) => rf[idKey] === f[idKey]);
+      if (!refField) {
+        throw ApiError.BadRequest(
+          "fieldNotFound",
+          `Consistency error: refField not found for ${idKey} ${f[idKey]}`,
+        );
+      }
+
+      return {
+        value: fileTypeCodes.includes(f.fieldType)
+          ? // если файловый тип
+            filesMap.get(f[idKey]!)
+            ? // если есть такой файл
+              filesUtils.convertMulterFileToStandardFile(
+                filesMap.get(f[idKey]!)!,
+              )
+            : // если нет файла
+              null
+          : // если не файл
+            f.value,
+        fieldDefinitionId: refField.fieldDefinitionId,
+        fieldType: f.fieldType,
+        [idKey]: f[idKey],
+      };
+    });
+
+    // Валидация
+    const validationResult = await this.validateFields(
+      fieldsToCheck.map((f) => ({ ...f, value: f.value })), // value уже обработано
+      referenceFields,
+    );
+
+    return {
+      isValid: validationResult.isValid,
+      messages: validationResult.messages,
+      checkedfields,
+    };
+  }
+
+  async saveCustomFieldValuesInTransaction({
+    tr,
+    parentId,
+    action,
+    inputFields,
+    files,
+    isDeleteBefore,
+  }: {
+    tr: DatabaseTransactionConnection;
+    parentId: string;
+    action: "activityPeReg" | "peCreate" | "userProfile";
+    inputFields: (CustomFieldInput & {
+      fieldType: z.infer<typeof FieldTypeCode>;
+    })[];
+    files: MulterFiles;
+    isDeleteBefore: false;
+  }): Promise<void>;
+  async saveCustomFieldValuesInTransaction({
+    tr,
+    parentId,
+    action,
+    inputFields,
+    files,
+    isDeleteBefore,
+  }: {
+    tr: DatabaseTransactionConnection;
+    parentId: string;
+    action: "activityPeReg" | "peCreate" | "userProfile";
+    inputFields: (CustomFieldInput & {
+      fieldType: z.infer<typeof FieldTypeCode>;
+      oldValue: z.infer<typeof InputFieldValue>;
+    })[];
+    files: MulterFiles;
+    isDeleteBefore: true;
+  }): Promise<void>;
+  async saveCustomFieldValuesInTransaction({
+    tr,
+    parentId,
+    action,
+    inputFields,
+    files,
+    isDeleteBefore,
+  }: {
+    tr: DatabaseTransactionConnection;
+    parentId: string;
+    action: "activityPeReg" | "peCreate" | "userProfile";
+    inputFields: (CustomFieldInput & {
+      fieldType: z.infer<typeof FieldTypeCode>;
+      oldValue?: z.infer<typeof InputFieldValue>;
+    })[];
+    files: MulterFiles;
+    isDeleteBefore: boolean;
+  }): Promise<void> {
+    // перменные для разных типов
+    let parentTableIdColumn: string; // "activity_reg_id" или "pe_id"
+    let valuesTable: string; // "act.ar_field_values" или "act.pe_field_values"
+    let idKeyForValuesTable: string; // "arff_id" или "pe_ff_id"
+    let idKeyForFields: "arffId" | "peFfId" | "userEfId";
+
+    if (action === "activityPeReg") {
+      parentTableIdColumn = "activity_reg_id";
+      valuesTable = "act.ar_field_values";
+      idKeyForValuesTable = "arff_id";
+      idKeyForFields = "arffId";
+    } else if (action === "peCreate") {
+      parentTableIdColumn = "pe_id";
+      valuesTable = "act.pe_field_values";
+      idKeyForValuesTable = "pe_ff_id";
+      idKeyForFields = "peFfId";
+    } else if (action === "userProfile") {
+      parentTableIdColumn = "user_id";
+      valuesTable = "ev.user_event_field_values";
+      idKeyForValuesTable = "user_ef_id";
+      idKeyForFields = "userEfId";
+    } else {
+      throw ApiError.BadRequest(
+        "actionNotFound",
+        `Consistency error: action ${action} not found`,
+      );
+    }
+    //
+
+    const filesMap = await this.createFilesMap(files);
+
+    for (const field of inputFields) {
+      const keyId = field[idKeyForFields];
+      if (!keyId)
+        throw ApiError.BadRequest(
+          "keyIdNotFound",
+          `Consistency error: keyId not found for ${idKeyForFields} ${field[idKeyForFields]}`,
+        );
+      let valueToSave: z.infer<typeof SavedFieldValue>;
+
+      // удаляем старые значения
+      if (isDeleteBefore) {
+        await tr.query(sql.unsafe`
+          delete from ${sql.identifier([...valuesTable.split(".")])}
+            where
+              ${sql.identifier([parentTableIdColumn])} = ${parentId}
+              and ${sql.identifier([idKeyForValuesTable])} = ${keyId};
+        `);
+
+        logger.info(`field.oldValue`, field.oldValue);
+        if (
+          field.oldValue &&
+          typeof field.oldValue === "object" &&
+          field.oldValue.fileName
+        ) {
+          logger.info(`Deleting old file ${field.oldValue.fileName}`);
+          filesUtils.deleteFile(field.oldValue.fileName);
+        }
+      }
+
+      const file = filesMap.get(keyId);
+      if (fileTypeCodes.includes(field.fieldType) && field.value !== null) {
+        if (!file) {
+          // Здесь предполагаем, что если fieldType=файловый тип, то файл должен быть.
+          throw ApiError.BadRequest(
+            "fileNotFound",
+            `Файл поля ${idKeyForFields}:${keyId} не найден на этапе сохранения`,
+          );
+        }
+
+        const savedFile = await filesUtils.saveFile(file);
+        valueToSave = {
+          originalName: savedFile.originalname,
+          fileName: savedFile.filename,
+          type: savedFile.type,
+        };
+      } else {
+        if (
+          typeof field.value !== "string" &&
+          typeof field.value !== "number" &&
+          typeof field.value !== "boolean" &&
+          field.value !== null
+        ) {
+          throw ApiError.BadRequest(
+            "valueNotString",
+            `Ошибка типа данных кастомного поля. Value: ${field.value}. Type: ${typeof field.value}. Field: ${field.arffId || field.peFfId || field.userEfId}`,
+          );
+        }
+
+        valueToSave = field.value;
+      }
+
+      await tr.query(sql.unsafe`
+        insert into ${sql.identifier([...valuesTable.split(".")])}
+          (${sql.identifier([parentTableIdColumn])}, ${sql.identifier([idKeyForValuesTable])}, value)
+        values
+          (${parentId}, ${keyId}, ${valueToSave ? sql.jsonb(valueToSave) : null})
+      `);
+    }
+  }
+}
+
+export const cCustomFieldsValidateService = new CCustomFieldsValidateService();

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

@@ -0,0 +1,266 @@
+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}`;
+  }
+};

+ 0 - 0
src/plugins/multer.ts


+ 64 - 0
src/utils/files-utils.ts

@@ -0,0 +1,64 @@
+import fs from "fs";
+import path from "path";
+
+// multer
+import multer from "multer";
+import { logger } from "#plugins/logger.js";
+export const upload = multer({
+  storage: multer.memoryStorage(),
+});
+
+export const filesDir = path.join(process.cwd(), "files");
+
+class FilesUtils {
+  // TODO: вынести в env
+
+  convertMulterFileToStandardFile(multerFile: Express.Multer.File) {
+    return new File([multerFile.buffer], multerFile.originalname, {
+      type: multerFile.mimetype,
+      lastModified: new Date().getTime(),
+    });
+  }
+
+  async saveFile(file: Express.Multer.File): Promise<{
+    originalname: string;
+    filename: string;
+    filePath: string;
+    type: "audio" | "img" | "video" | "pdf" | "other";
+  }> {
+    const filename = `${Date.now()}-${file.originalname}`;
+    const filePath = path.join(filesDir, filename);
+    await fs.promises.writeFile(filePath, file.buffer);
+
+    const type = file.mimetype.includes("audio/")
+      ? "audio"
+      : file.mimetype.includes("image/")
+        ? "img"
+        : file.mimetype.includes("video/")
+          ? "video"
+          : file.mimetype.includes("application/pdf")
+            ? "pdf"
+            : "other";
+
+    return { originalname: file.originalname, filename, filePath, type };
+  }
+
+  createFilesDir() {
+    if (!fs.existsSync(filesDir)) {
+      fs.mkdirSync(filesDir);
+    }
+  }
+
+  deleteFile(fileName: string) {
+    try {
+      const filePath = path.join(filesDir, fileName);
+      if (fs.existsSync(filePath)) {
+        fs.unlinkSync(filePath);
+      }
+    } catch (error) {
+      logger.error(`Error deleting file ${fileName}:`, error);
+    }
+  }
+}
+
+export const filesUtils = new FilesUtils();

+ 3 - 1
tsconfig.json

@@ -1,5 +1,6 @@
 {
   "compilerOptions": {
+    // TODO: сделать один файл конфигурации для бэка и фронта
     /* Visit https://aka.ms/tsconfig to read more about this file */
 
     /* Projects */
@@ -13,7 +14,8 @@
     /* Language and Environment */
     "target": "es2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
     "lib": [
-      "ES6"
+      "ES6",
+      "DOM"
     ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
     // "jsx": "preserve",                                /* Specify what JSX code is generated. */
     // "experimentalDecorators": true,                   /* Enable experimental support for legacy experimental decorators. */