Browse Source

Изменение схемы регистрации

Vadim 2 months ago
parent
commit
6d4c17a80d

+ 1 - 0
.example.env

@@ -2,6 +2,7 @@
 PORT = "3000"
 LOGS_LEVEL = "info"
 API_URL = ""
+APP_BASE_URL = ""
 
 # DB
 DB_HOST = "localhost"

+ 29 - 24
src/api/v_0.1.0/client/auth-api.ts

@@ -18,31 +18,11 @@ class AuthApi {
 
   // /auth/registration
   POST_Registration = {
-    req: z.object({
-      email: z.string().email(),
-    }),
-    res: z.discriminatedUnion("code", [
-      z.object({
-        code: z.enum(["pinIsSent"]),
-        transactionId: z.string().uuid(),
-      }),
-      z.object({
-        code: z.enum(["pinIsNotSent"]),
-      }),
-      z.object({
-        code: z.enum(["alreadyExists"]),
-      }),
-    ]),
-  };
-
-  // /auth/confirm-registration
-  POST_ConfirmRegistration = {
     req: {
       formData: {
         body: z.object({
+          email: z.string().email(),
           password: z.string(),
-          transactionId: z.string().uuid(),
-          confirmPin: z.string(),
           fields: z.array(
             z.object({
               userEfId: z.string().uuid(),
@@ -53,6 +33,9 @@ class AuthApi {
       },
     },
     res: z.discriminatedUnion("code", [
+      z.object({
+        code: z.enum(["emailAlreadyRegistered"]),
+      }),
       z.object({
         code: z.literal("registered"),
         accessToken: z.string(),
@@ -61,9 +44,6 @@ class AuthApi {
           userId: z.string().uuid(),
         }),
       }),
-      z.object({ code: z.literal("pinIsWrong"), triesRemained: z.number() }),
-      z.object({ code: z.literal("pinIsRotten") }),
-      z.object({ code: z.literal("tooManyTries") }),
     ]),
   };
 
@@ -133,5 +113,30 @@ class AuthApi {
       permissions: z.array(z.string()),
     }),
   };
+
+  POST_VerifyEmail = {
+    req: {
+      query: z.object({
+        token: z.string(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success", "tokenIsInvalid"]),
+    }),
+  };
+
+  POST_PasswordReset = {
+    req: {
+      query: z.object({
+        token: z.string(),
+      }),
+      body: z.object({
+        password: z.string(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success", "tokenIsInvalid"]),
+    }),
+  };
 }
 export const authApi = new AuthApi();

+ 1 - 0
src/api/v_0.1.0/client/client-users-api.ts

@@ -18,6 +18,7 @@ class ClientUsersApi {
         ),
         isChild: z.boolean(),
         email: z.string().email(),
+        isEmailConfirmed: z.boolean(),
       }),
     }),
   };

+ 1 - 0
src/config/config.ts

@@ -54,6 +54,7 @@ const ConfigSchema = z.object({
     .enum(["error", "warn", "info", "verbose", "debug", "silly"])
     .default("info"),
   API_URL: requiredString("API_URL"),
+  APP_BASE_URL: requiredString("APP_BASE_URL"),
 
   // DB
   DB_HOST: requiredString("DB_HOST"),

+ 14 - 7
src/db/db-schema.ts

@@ -10,6 +10,7 @@ const DbSchema = {
       wrongPassTries: z.number().int().default(0),
       isChild: z.boolean(),
       parentId: z.string().uuid().nullable(),
+      isEmailConfirmed: z.boolean(),
     },
     roles: {
       roleId: z.string().uuid(),
@@ -46,19 +47,25 @@ const DbSchema = {
       userId: z.string().uuid(),
       eventId: z.string().uuid(),
     },
-    confirmPins: {
-      transactionId: z.string().uuid(),
-      email: z.string().email(),
-      confirmPin: z.string(),
-      createTime: z.string().datetime(), // timestamp в БД
-      wrongPinTries: z.number().int().default(0), // int2 в БД
-    },
     userRefreshTokens: {
       tokenId: z.string().uuid(),
       userId: z.string().uuid(),
       refreshToken: z.string(),
       rotDate: z.string().datetime(), // timestamp в БД
     },
+    accountVerificationTokens: {
+      verificationTokenId: z.string().uuid(),
+      userId: z.string().uuid(),
+      token: z.string(),
+      tokenType: z.enum([
+        "EMAIL_VERIFICATION",
+        "PASSWORD_RESET",
+        "LAZY_REGISTGRATION",
+      ]),
+      createdAt: z.string().datetime(),
+      expiresAt: z.string().datetime(),
+      usedAt: z.string().datetime().nullable(),
+    },
   },
 
   ev: {

+ 6 - 195
src/modules/client/users/auth/routers/auth-controller.ts

@@ -12,208 +12,15 @@ import { ApiError } from "#exceptions/api-error.js";
 // other
 import { z } from "zod";
 import bcript from "bcrypt";
-import { v7 as uuidv7 } from "uuid";
 
 import { UserAuthService } from "../services/user-auth-service.js";
-import { ConfirmPinsService } from "#modules/client/users/confirm-pins/confirm-pins-service.js";
+
 import { RouterUtils } from "#utils/router-utils.js";
 import { config } from "#config";
 import { Request, Response } from "express";
-import { cUsersService } from "../../c-users-service.js";
-import sessionService from "../services/session-service.js";
 import tokenService from "../services/token-service.js";
-import { cCustomFieldsValidateService } from "#modules/client/custom-fields/c-cf-validate-service.js";
 
 class authController {
-  // --- Регистрация ---
-  async getUserRegData(req: Request, res: Response) {
-    const event = await sessionService.getCurrentEventFromReq(req);
-
-    const regData = await cUsersService.getUserEventFieldsWithValidators(
-      event.eventId,
-    );
-
-    RouterUtils.validAndSendResponse(api.client.auth.GET_UserRegData.res, res, {
-      code: "success",
-      fields: [...regData],
-    });
-  }
-
-  async register(
-    req: Request,
-    res: Response,
-    // next: NextFunction
-  ) {
-    // валидация запроса
-    const { email } = api.client.auth.POST_Registration.req.parse(req.body);
-
-    const isUserExist = await UserAuthService.checkUserExistByEmail(email);
-
-    // если пользователь уже зарегистрирован
-    if (isUserExist) {
-      RouterUtils.validAndSendResponse(
-        api.client.auth.POST_Registration.res,
-        res,
-        { code: "alreadyExists" },
-        400,
-      );
-
-      return;
-    }
-
-    // отправка пина
-    const transactionId = uuidv7();
-    try {
-      await ConfirmPinsService.sendConfirmPin({
-        transactionId,
-        email,
-        actionType: "registration",
-      });
-    } catch {
-      RouterUtils.validAndSendResponse(
-        api.client.auth.POST_Registration.res,
-        res,
-        { code: "pinIsNotSent" },
-        400,
-      );
-      return;
-    }
-
-    RouterUtils.validAndSendResponse(
-      api.client.auth.POST_Registration.res,
-      res,
-      {
-        code: "pinIsSent",
-        transactionId: transactionId,
-      },
-    );
-  }
-
-  async confirmRegistration(req: Request, res: Response) {
-    // валидация запроса
-    const { password, transactionId, confirmPin, fields } =
-      api.client.auth.POST_ConfirmRegistration.req.formData.body.parse(
-        JSON.parse(req.body.body),
-      );
-
-    const event = await sessionService.getCurrentEventFromReq(req);
-    const files = req.files;
-
-    // проверка пина
-    const pinInfo = await ConfirmPinsService.checkConfirmPin(
-      transactionId,
-      confirmPin,
-    );
-
-    switch (pinInfo.status) {
-      case "rotten": {
-        RouterUtils.validAndSendResponse(
-          api.client.auth.POST_ConfirmRegistration.res,
-          res,
-          { code: "pinIsRotten" },
-          400,
-        );
-        return;
-      }
-      case "tooManyTries": {
-        RouterUtils.validAndSendResponse(
-          api.client.auth.POST_ConfirmRegistration.res,
-          res,
-          { code: "tooManyTries" },
-          400,
-        );
-        return;
-      }
-      case "wrong": {
-        RouterUtils.validAndSendResponse(
-          api.client.auth.POST_ConfirmRegistration.res,
-          res,
-          {
-            code: "pinIsWrong",
-            triesRemained: pinInfo.triesRemained,
-          },
-          400,
-        );
-        return;
-      }
-    }
-
-    // пин правильный
-    const email = pinInfo.email;
-    // регистрация
-    const hashPassword = await bcript.hash(password, 3);
-
-    // поля пользователя
-    const userData = await cUsersService.getUserEventFieldsWithValidators(
-      event.eventId,
-    );
-
-    const refFields = userData.map((f) => ({
-      ...f,
-      idKey: "userEfId",
-    }));
-
-    // валидация полей
-    const validationResult =
-      await cCustomFieldsValidateService.processAndValidateFields({
-        inputFields: fields,
-        referenceFields: refFields,
-        files,
-        idKey: "userEfId",
-        addOldValue: false,
-      });
-
-    if (!validationResult.isValid)
-      throw ApiError.BadRequest(
-        "fieldsValidationFailed",
-        JSON.stringify(validationResult.messages),
-      );
-
-    const validatedFields = validationResult.checkedfields;
-    // вставляем в базу и сохраняем файлы
-    const userId = uuidv7();
-    await updPool.transaction(async (tr) => {
-      await tr.query(
-        sql.unsafe`
-            insert into usr.users 
-              (user_id, email, password) 
-            values 
-              (${userId}, ${email}, ${hashPassword})`,
-      );
-
-      await cCustomFieldsValidateService.saveCustomFieldValuesInTransaction({
-        tr,
-        parentId: userId,
-        action: "userProfile",
-        inputFields: validatedFields,
-        files,
-        isDeleteBefore: false,
-      });
-    });
-
-    // токены
-    const { accessToken, refreshToken } = tokenService.generateTokens({
-      email,
-      userId,
-    });
-    await tokenService.insertRefreshToken(userId, refreshToken);
-
-    tokenService.setRefreshTokenInCookie(res, refreshToken);
-
-    RouterUtils.validAndSendResponse(
-      api.client.auth.POST_ConfirmRegistration.res,
-      res,
-      {
-        code: "registered",
-        accessToken,
-        userData: {
-          email,
-          userId,
-        },
-      },
-    );
-  }
-
   async login(req: Request, res: Response) {
     // валидация запроса
     const { email, password } = api.client.auth.POST_Login.req.parse(req.body);
@@ -289,7 +96,11 @@ class authController {
       email,
       userId: user.userId,
     });
-    await tokenService.insertRefreshToken(user.userId, refreshToken);
+    await tokenService.insertRefreshToken({
+      tr: updPool,
+      userId: user.userId,
+      refreshToken,
+    });
 
     tokenService.setRefreshTokenInCookie(res, refreshToken);
 

+ 14 - 6
src/modules/client/users/auth/routers/auth-router.ts

@@ -1,22 +1,21 @@
-import { upload } from "#utils/files-utils.js";
 import { RouterUtils } from "#utils/router-utils.js";
 import { AuthController } from "./auth-controller.js";
 
 import express from "express";
+import { registrationController } from "./registration-controller.js";
+import { upload } from "#utils/files-utils.js";
 const router = express.Router();
 export default router;
 
 router.get(
   "/user-reg-data",
-  RouterUtils.asyncHandler(AuthController.getUserRegData),
+  RouterUtils.asyncHandler(registrationController.getUserRegData),
 );
 
-router.post("/registration", RouterUtils.asyncHandler(AuthController.register));
-
 router.post(
-  "/confirm-registration",
+  "/registration",
   upload.any(),
-  RouterUtils.asyncHandler(AuthController.confirmRegistration),
+  RouterUtils.asyncHandler(registrationController.registration),
 );
 
 router.post("/login", RouterUtils.asyncHandler(AuthController.login));
@@ -28,3 +27,12 @@ router.post(
 );
 
 router.get("/refresh", RouterUtils.asyncHandler(AuthController.refresh));
+
+router.post(
+  "/verify-email",
+  RouterUtils.asyncHandler(registrationController.verifyEmail),
+);
+router.post(
+  "/password-reset",
+  RouterUtils.asyncHandler(registrationController.passwordReset),
+);

+ 204 - 0
src/modules/client/users/auth/routers/registration-controller.ts

@@ -0,0 +1,204 @@
+// db
+import { updPool } from "#db";
+
+// api
+import { api } from "#api";
+
+// error
+import { ApiError } from "#exceptions/api-error.js";
+
+import { UserAuthService } from "../services/user-auth-service.js";
+import { RouterUtils } from "#utils/router-utils.js";
+
+import { Request, Response } from "express";
+import { cUsersService } from "../../c-users-service.js";
+import sessionService from "../services/session-service.js";
+import tokenService from "../services/token-service.js";
+import { verificationTokensService } from "../../verification-tokens/verification-tokens-service.js";
+// import { logger } from "#plugins/logger.js";
+
+class RegistrationController {
+  async getUserRegData(req: Request, res: Response) {
+    const event = await sessionService.getCurrentEventFromReq(req);
+
+    const regData = await cUsersService.getUserEventFieldsWithValidators(
+      event.eventId,
+    );
+
+    RouterUtils.validAndSendResponse(api.client.auth.GET_UserRegData.res, res, {
+      code: "success",
+      fields: [...regData],
+    });
+  }
+
+  async registration(req: Request, res: Response) {
+    // валидация запроса
+    const { email, password, fields } =
+      api.client.auth.POST_Registration.req.formData.body.parse(
+        JSON.parse(req.body.body),
+      );
+
+    const event = await sessionService.getCurrentEventFromReq(req);
+    const files = req.files;
+
+    await updPool.transaction(async (tr) => {
+      const result = await UserAuthService.createUserWithFields({
+        tr,
+        email,
+        password,
+        fields,
+        files,
+        eventId: event.eventId,
+      });
+      if (result.status === "emailAlreadyRegistered") {
+        RouterUtils.validAndSendResponse(
+          api.client.auth.POST_Registration.res,
+          res,
+          { code: "emailAlreadyRegistered" },
+          400,
+        );
+        return;
+      }
+      if (result.status === "fieldsValidationFailed") {
+        throw ApiError.BadRequest(
+          "fieldsValidationFailed",
+          "Форма не прошла валидацию",
+          result.messages,
+        );
+      }
+
+      const userId = result.userId;
+
+      // токены
+      const { accessToken, refreshToken } = tokenService.generateTokens({
+        email,
+        userId,
+      });
+      await tokenService.insertRefreshToken({ tr, userId, refreshToken });
+
+      tokenService.setRefreshTokenInCookie(res, refreshToken);
+
+      // асинхронно отправляем ссылку на подтверждение почты
+      await verificationTokensService.sendVerificationLink({
+        userId,
+        email,
+        type: "emailVerification",
+      });
+      // .catch((e) => {
+      //   logger.error({
+      //     message: "Verification link is not sent",
+      //     email,
+      //     userId,
+      //     error: e,
+      //   });
+      // });
+
+      RouterUtils.validAndSendResponse(
+        api.client.auth.POST_Registration.res,
+        res,
+        {
+          code: "registered",
+          accessToken,
+          userData: {
+            email,
+            userId,
+          },
+        },
+      );
+    });
+  }
+
+  async verifyEmail(req: Request, res: Response) {
+    const { token } = api.client.auth.POST_VerifyEmail.req.query.parse(
+      req.query,
+    );
+
+    const userToken = await verificationTokensService.getUserByToken({ token });
+    if (!userToken || userToken.tokenType !== "EMAIL_VERIFICATION") {
+      RouterUtils.validAndSendResponse(
+        api.client.auth.POST_VerifyEmail.res,
+        res,
+        { code: "tokenIsInvalid" },
+        400,
+      );
+      return;
+    }
+
+    const isTokenValid = await verificationTokensService.verifyToken({
+      token,
+      type: "emailVerification",
+      userId: userToken.userId,
+    });
+    if (!isTokenValid) {
+      RouterUtils.validAndSendResponse(
+        api.client.auth.POST_VerifyEmail.res,
+        res,
+        { code: "tokenIsInvalid" },
+        400,
+      );
+      return;
+    }
+
+    await updPool.transaction(async (tr) => {
+      await verificationTokensService.markTokenAsUsed({
+        tr,
+        token,
+        type: "emailVerification",
+      });
+
+      await verificationTokensService.verifyEmail({
+        tr,
+        userId: userToken.userId,
+      });
+
+      RouterUtils.validAndSendResponse(
+        api.client.auth.POST_VerifyEmail.res,
+        res,
+        { code: "success" },
+      );
+    });
+  }
+
+  async passwordReset(req: Request, res: Response) {
+    const { token } = api.client.auth.POST_PasswordReset.req.query.parse(
+      req.query,
+    );
+    const { password } = api.client.auth.POST_PasswordReset.req.body.parse(
+      req.body,
+    );
+
+    const userToken = await verificationTokensService.getUserByToken({ token });
+    if (!userToken || userToken.tokenType !== "LAZY_REGISTGRATION") {
+      RouterUtils.validAndSendResponse(
+        api.client.auth.POST_PasswordReset.res,
+        res,
+        { code: "tokenIsInvalid" },
+        400,
+      );
+      return;
+    }
+    await updPool.transaction(async (tr) => {
+      await verificationTokensService.markTokenAsUsed({
+        tr,
+        token,
+        type: "passwordReset",
+      });
+
+      await UserAuthService.updatePassword({
+        tr,
+        userId: userToken.userId,
+        password,
+      });
+
+      // TODO: сбросить счетчик неправильных попыток ввода пароля
+
+      RouterUtils.validAndSendResponse(
+        api.client.auth.POST_PasswordReset.res,
+        res,
+        { code: "success" },
+      );
+    });
+  }
+}
+
+export const registrationController = new RegistrationController();

+ 11 - 3
src/modules/client/users/auth/services/token-service.ts

@@ -4,7 +4,7 @@ import jwt from "jsonwebtoken";
 
 // база данных
 import { selPool, updPool } from "#db";
-import { sql } from "slonik";
+import { DatabaseTransactionConnection, sql } from "slonik";
 
 // types
 import { TokenPayload } from "../types/token-playload-type.js";
@@ -47,9 +47,17 @@ class TokenService {
    * @param userId - id пользователя
    * @param refreshToken - токен, который будет вставлен
    */
-  async insertRefreshToken(userId: string, refreshToken: string) {
+  async insertRefreshToken({
+    tr,
+    userId,
+    refreshToken,
+  }: {
+    tr: DatabaseTransactionConnection;
+    userId: string;
+    refreshToken: string;
+  }) {
     const id = v7();
-    await updPool.query(
+    await tr.query(
       sql.unsafe`
       insert into usr.user_refresh_tokens
         (token_id, user_id, refresh_token)

+ 132 - 1
src/modules/client/users/auth/services/user-auth-service.ts

@@ -1,6 +1,20 @@
 // db
 import { selPool, updPool } from "#db/db.js";
-import { sql } from "slonik";
+import { DatabaseTransactionConnection, sql } from "slonik";
+import bcrypt from "bcrypt";
+import { cUsersService } from "../../c-users-service.js";
+import { cCustomFieldsValidateService } from "#modules/client/custom-fields/c-cf-validate-service.js";
+import { v7 as uuidv7 } from "uuid";
+import { z } from "zod";
+import { InputFieldValue } from "#api/v_0.1.0/types/custom-fields-types.js";
+import { MulterFiles } from "#utils/files-utils.js";
+
+const UserFieldsValuesSchema = z.array(
+  z.object({
+    userEfId: z.string().uuid(),
+    value: InputFieldValue,
+  }),
+);
 
 class userAuthService {
   async checkUserExistByEmail(email: string) {
@@ -32,6 +46,123 @@ class userAuthService {
         user_id = ${userId}`,
     );
   }
+
+  async updatePassword({
+    tr,
+    userId,
+    password,
+  }: {
+    tr: DatabaseTransactionConnection;
+    userId: string;
+    password: string;
+  }) {
+    const hashedPassword = await bcrypt.hash(password, 10);
+
+    await tr.query(
+      sql.unsafe`
+      update 
+        usr.users 
+      set 
+        password = ${hashedPassword} 
+      where 
+        user_id = ${userId}`,
+    );
+  }
+
+  async createUser({
+    tr,
+    userId,
+    email,
+    password,
+  }: {
+    tr: DatabaseTransactionConnection;
+    userId: string;
+    email: string;
+    password?: string;
+  }) {
+    const hashPassword = password ? await bcrypt.hash(password, 10) : null;
+    await tr.query(
+      sql.unsafe`
+        insert into usr.users 
+        (user_id, email, password) 
+        values 
+        (${userId}, ${email}, ${hashPassword})`,
+    );
+  }
+
+  async createUserWithFields({
+    tr,
+    email,
+    password,
+    fields,
+    files,
+    eventId,
+  }: {
+    tr: DatabaseTransactionConnection;
+    email: string;
+    password?: string | undefined;
+    fields: z.infer<typeof UserFieldsValuesSchema>;
+    files: MulterFiles;
+    eventId: string;
+  }): Promise<
+    | { status: "success"; userId: string }
+    | { status: "emailAlreadyRegistered" }
+    | { status: "fieldsValidationFailed"; messages: string }
+  > {
+    // проверка
+    const isUserExist = await UserAuthService.checkUserExistByEmail(email);
+    // если пользователь уже зарегистрирован
+    if (isUserExist) {
+      return { status: "emailAlreadyRegistered" };
+    }
+
+    // поля пользователя
+    const userData =
+      await cUsersService.getUserEventFieldsWithValidators(eventId);
+
+    const refFields = userData.map((f) => ({
+      ...f,
+      idKey: "userEfId",
+    }));
+
+    // валидация полей
+    const validationResult =
+      await cCustomFieldsValidateService.processAndValidateFields({
+        inputFields: fields,
+        referenceFields: refFields,
+        files,
+        idKey: "userEfId",
+        addOldValue: false,
+      });
+
+    if (!validationResult.isValid)
+      return {
+        status: "fieldsValidationFailed",
+        messages: JSON.stringify(validationResult.messages),
+      };
+
+    const validatedFields = validationResult.checkedfields;
+    // вставляем в базу и сохраняем файлы
+    const userId = uuidv7();
+
+    await this.createUser({
+      tr,
+      userId,
+      email,
+      password,
+    });
+
+    await cCustomFieldsValidateService.saveCustomFieldValuesInTransaction({
+      tr,
+      parentId: userId,
+      action: "userProfile",
+      inputFields: validatedFields,
+      files,
+      isDeleteBefore: false,
+    });
+
+    return { status: "success", userId };
+  }
 }
 
 export const UserAuthService = new userAuthService();

+ 1 - 0
src/modules/client/users/c-users-controller.ts

@@ -39,6 +39,7 @@ class ClientUsersController {
           fields: [...userData],
           isChild: user.isChild,
           email,
+          isEmailConfirmed: user.isEmailConfirmed,
         },
       },
     );

+ 10 - 1
src/modules/client/users/c-users-service.ts

@@ -102,8 +102,17 @@ class CUsersService {
         z.object({
           userId: DbSchema.usr.users.userId,
           isChild: DbSchema.usr.users.isChild,
+          isEmailConfirmed: DbSchema.usr.users.isEmailConfirmed,
         }),
-      )`select user_id as "userId", is_child as "isChild" from usr.users where user_id = ${userId}`,
+      )`
+      select 
+      user_id as "userId", 
+      is_child as "isChild",
+      is_email_confirmed as "isEmailConfirmed"
+      from 
+        usr.users 
+      where 
+        user_id = ${userId}`,
     );
   }
 

+ 0 - 158
src/modules/client/users/confirm-pins/confirm-pins-service.ts

@@ -1,158 +0,0 @@
-import { MailService } from "#services/mail-service.js";
-import { logger } from "#logger";
-import { selPool, updPool } from "#db";
-import { sql } from "slonik";
-
-import { DbSchema } from "#db-schema";
-import { z } from "zod";
-
-// dayjs
-import { dayjs, DayjsUtils } from "#dayjs";
-import { config } from "#config";
-
-class confirmPinsService {
-  // privalte
-  private genRandom4DigitNumber() {
-    return Math.floor(1000 + Math.random() * 9000);
-  }
-
-  private async deleteConfirmPin(transactionId: string) {
-    await updPool.query(
-      sql.unsafe`
-      delete from 
-        usr.confirm_pins 
-      where 
-        transaction_id = ${transactionId}`,
-    );
-  }
-
-  private async pinTriesIncrement(transactionId: string) {
-    await updPool.query(
-      sql.unsafe`
-      update 
-        usr.confirm_pins 
-      set 
-        wrong_pin_tries = wrong_pin_tries + 1 
-      where 
-        transaction_id = ${transactionId}`,
-    );
-  }
-  //
-  // public
-  //
-  async sendConfirmPin({
-    transactionId,
-    email,
-    actionType,
-  }: {
-    transactionId: string;
-    email: string;
-    actionType: "registration";
-  }) {
-    const confirmPin = this.genRandom4DigitNumber();
-    // удаляем если пин уже есть
-    await this.deleteConfirmPin(transactionId);
-    // отправка
-    const mailBody = `
-    <div>
-      <h1>Ваш временный код:</h1>
-      <h4> <a href="${confirmPin}">${confirmPin}</a> </h4>
-    </div>
-`;
-    await MailService.sendMail(email, "Ваш код для EVENT", mailBody);
-    // бд
-    await updPool.query(
-      sql.unsafe`
-      insert into usr.confirm_pins (
-        transaction_id,
-        email,
-        confirm_pin,
-        create_time,
-        action_type)
-      values (
-        ${transactionId}, 
-        ${email}, 
-        ${confirmPin}, 
-        ${DayjsUtils.createDayjsUtcWithoutOffset().toISOString()},
-        ${actionType})`,
-    );
-    logger.info("Отправлен временный код: ", {
-      email,
-      confirmPin,
-    });
-  }
-
-  async checkConfirmPin(
-    transactionId: string,
-    confirmPin: string,
-  ): Promise<
-    | { status: "correct"; email: string }
-    | { status: "rotten" }
-    | { status: "tooManyTries" }
-    | {
-        status: "wrong";
-        triesRemained: number;
-      }
-  > {
-    const pinInfo = await selPool.maybeOne(
-      sql.type(
-        z.object({
-          confirmPin: DbSchema.usr.confirmPins.confirmPin,
-          email: DbSchema.usr.confirmPins.email,
-          createTime: DbSchema.usr.confirmPins.createTime,
-          wrongPinTries: DbSchema.usr.confirmPins.wrongPinTries,
-        }),
-      )`
-      select 
-        confirm_pin as "confirmPin", 
-        email, 
-        create_time as "createTime", 
-        wrong_pin_tries as "wrongPinTries"
-      from 
-        usr.confirm_pins 
-      where 
-        transaction_id = ${transactionId}`,
-    );
-
-    // не существует
-    if (!pinInfo) {
-      return { status: "rotten" };
-    }
-
-    // много попыток
-    if (pinInfo.wrongPinTries > config.CONFIRM_PIN_MAX_TRIES - 1) {
-      return { status: "tooManyTries" };
-    }
-
-    // просрочка
-    if (
-      DayjsUtils.createDayjsUtcWithoutOffset().isAfter(
-        dayjs
-          .utc(pinInfo.createTime)
-          .add(config.CONFIRM_PIN_LIFETIME_MINS, "minutes"),
-      )
-    ) {
-      await this.deleteConfirmPin(transactionId);
-      return { status: "rotten" };
-    }
-
-    // неправильный
-    if (pinInfo.confirmPin !== confirmPin) {
-      await this.pinTriesIncrement(transactionId);
-
-      const triesRemained =
-        config.CONFIRM_PIN_MAX_TRIES - 1 - pinInfo.wrongPinTries;
-
-      return {
-        status: "wrong",
-        triesRemained,
-      };
-    }
-
-    // правильный
-    await this.deleteConfirmPin(transactionId);
-    return { status: "correct", email: pinInfo.email };
-  }
-}
-
-export const ConfirmPinsService = new confirmPinsService();

+ 231 - 0
src/modules/client/users/verification-tokens/verification-tokens-service.ts

@@ -0,0 +1,231 @@
+import { config } from "#config/config.js";
+import { DbSchema } from "#db/db-schema.js";
+import { selPool, updPool } from "#db/db.js";
+import { dayjs } from "#plugins/dayjs.js";
+import { MailService } from "#services/mail-service.js";
+import crypto from "crypto";
+import { DatabaseTransactionConnection, sql } from "slonik";
+import { v7 } from "uuid";
+import { z } from "zod";
+
+type VerificationTokenType = "emailVerification" | "passwordReset";
+
+class VerificationTokensService {
+  async sendVerificationLink({
+    userId,
+    email,
+    type,
+  }: {
+    userId: string;
+    email: string;
+    type: VerificationTokenType;
+  }) {
+    await updPool.transaction(async (tr) => {
+      //  аннулируем старые токены
+      await this.deleteVerificationTokens({ tr, userId, type });
+
+      const token = this.generateVerificationToken();
+
+      await this.insertTokenIntoDb({ tr, userId, token, type });
+
+      // отправка
+      await this.sendVerificationToken({ email, token, type });
+    });
+  }
+
+  private generateVerificationToken() {
+    return crypto.randomBytes(32).toString("hex");
+  }
+
+  private async deleteVerificationTokens({
+    tr,
+    userId,
+    type,
+  }: {
+    tr: DatabaseTransactionConnection;
+    userId: string;
+    type: VerificationTokenType;
+  }) {
+    await tr.query(sql.unsafe`
+        delete from 
+            usr.account_verification_tokens
+        where
+            user_id = ${userId} and
+            token_type = ${this.tokenType(type)}
+    `);
+  }
+
+  private tokenLifeTimeHours(type: VerificationTokenType) {
+    switch (type) {
+      case "emailVerification":
+        return 24;
+      case "passwordReset":
+        return 1;
+    }
+  }
+
+  private linkPath(type: VerificationTokenType) {
+    switch (type) {
+      case "emailVerification":
+        return "/verify-email";
+      case "passwordReset":
+        return "/reset-password";
+    }
+  }
+
+  private tokenType(type: VerificationTokenType) {
+    switch (type) {
+      case "emailVerification":
+        return "EMAIL_VERIFICATION";
+      case "passwordReset":
+        return "PASSWORD_RESET";
+    }
+  }
+
+  private async insertTokenIntoDb({
+    tr,
+    userId,
+    token,
+    type,
+  }: {
+    tr: DatabaseTransactionConnection;
+    userId: string;
+    token: string;
+    type: VerificationTokenType;
+  }) {
+    const expiresAt = dayjs()
+      .add(this.tokenLifeTimeHours(type), "hours")
+      .toISOString();
+
+    await tr.query(sql.unsafe`
+        insert into 
+            usr.account_verification_tokens (verification_token_id, user_id, token, token_type, expires_at)
+        values
+            (${v7()}, ${userId}, ${token}, ${this.tokenType(type)}, ${expiresAt})
+    `);
+  }
+
+  private async sendVerificationToken({
+    email,
+    token,
+    type,
+  }: {
+    email: string;
+    token: string;
+    type: VerificationTokenType;
+  }) {
+    const link = `${config.APP_BASE_URL}${this.linkPath(type)}?token=${token}`;
+
+    let mailBody: string;
+    let title: string;
+
+    switch (type) {
+      case "emailVerification": {
+        title = "Подтверждение почты";
+        mailBody = `
+                <div>
+                  <h1>${title}</h1>
+                  <h4> <a href="${link}">${link}</a> </h4>
+                </div>
+            `;
+        break;
+      }
+      case "passwordReset": {
+        title = "Сброс пароля";
+        mailBody = `
+                <div>
+                  <h1>${title}</h1>
+                  <h4> <a href="${link}">${link}</a> </h4>
+                </div>
+            `;
+        break;
+      }
+    }
+    await MailService.sendMail(email, title, mailBody);
+  }
+
+  async getUserByToken({ token }: { token: string }) {
+    return await selPool.maybeOne(sql.type(
+      z.object({
+        userId: DbSchema.usr.users.userId,
+        email: DbSchema.usr.users.email,
+        tokenType: DbSchema.usr.accountVerificationTokens.tokenType,
+      }),
+    )`
+        select
+            u.user_id "userId",
+            u.email,
+            t.token_type "tokenType"
+        from
+            usr.account_verification_tokens t
+        join
+            usr.users u
+        on
+            t.user_id = u.user_id
+        where
+            token = ${token}
+    `);
+  }
+
+  async verifyToken({
+    token,
+    type,
+    userId,
+  }: {
+    token: string;
+    type: VerificationTokenType;
+    userId: string;
+  }) {
+    return await selPool.exists(sql.unsafe`
+        select
+            1
+        from
+            usr.account_verification_tokens
+        where
+            token = ${token} and
+            token_type = ${this.tokenType(type)} and
+            user_id = ${userId} and
+            expires_at > ${dayjs().toISOString()} and
+            used_at is null
+    `);
+  }
+
+  async markTokenAsUsed({
+    tr,
+    token,
+    type,
+  }: {
+    tr: DatabaseTransactionConnection;
+    token: string;
+    type: VerificationTokenType;
+  }) {
+    await tr.query(sql.unsafe`
+        update
+            usr.account_verification_tokens
+        set
+            used_at = ${dayjs().toISOString()}
+        where
+            token = ${token} and
+            token_type = ${this.tokenType(type)}
+    `);
+  }
+
+  async verifyEmail({
+    tr,
+    userId,
+  }: {
+    tr: DatabaseTransactionConnection;
+    userId: string;
+  }) {
+    await tr.query(sql.unsafe`
+        update
+            usr.users
+        set
+            is_email_confirmed = true
+        where
+            user_id = ${userId}
+    `);
+  }
+}
+
+export const verificationTokensService = new VerificationTokensService();