Интеграция и сохранение выбранного языка пользователя в базу данных в фулстек-приложении на «Angular» и «NestJS»

от автора

Предыдущая статья: Поддержка временных зон в фулстек-приложении на основе NestJS и Angular: работа с REST и WebSockets

Этот пост не претендует на масштабность, но поскольку я последовательно документирую все этапы разработки бойлерплейта в формате статей, решил описать и эту задачу.

Здесь я приведу пример миграции базы данных для добавления нового поля, а также покажу, как реализовать соответствующий функционал на бэкенде и фронтенде для изменения этого значения.

Язык пользователя, как и временная зона, будет храниться в базе данных Auth.

1. Создание миграции для добавления нового поля

На данном этапе мы выполним миграцию базы данных, добавив новое поле для хранения выбранной информации.

Команды

# Create empty migration npm run flyway:create:auth --args=AddFieldLangToAuthUser 

Заполняем файл миграции SQL-скриптом, необходимым для создания поля.

Обновляем файл libs/core/auth/src/migrations/V202412141339__AddFieldLangToAuthUser.sql

DO $$ BEGIN     ALTER TABLE "AuthUser"         ADD "lang" varchar(2); EXCEPTION     WHEN duplicate_column THEN         NULL; END $$; 

2. Применение созданных миграций и обновление схем «Prisma»

После завершения создания миграций необходимо применить их, обновить схемы Prisma для всех баз данных и перезапустить генераторы Prisma.

Команды

npm run db:create-and-fill npm run prisma:pull npm run generate 

Файл схемы для новой базы данных libs/core/auth/src/prisma/schema.prisma

generator client {   provider      = "prisma-client-js"   output        = "../../../../../node_modules/@prisma/auth-client"   binaryTargets = ["native", "linux-musl", "debian-openssl-1.1.x", "linux-musl-openssl-3.0.x"]   engineType    = "binary" }  generator prismaClassGenerator {   provider                        = "prisma-generator-nestjs-dto"   output                          = "../lib/generated/rest/dto"   flatResourceStructure           = "false"   dtoSuffix                       = "Dto"   entityPrefix                    = ""   prettier                        = "true"   annotateAllDtoProperties        = "true"   fileNamingStyle                 = "kebab"   noDependencies                  = "false"   updateDtoPrefix                 = "Update"   exportRelationModifierClasses   = "true"   entitySuffix                    = ""   outputToNestJsResourceStructure = "false"   reExport                        = "false"   definiteAssignmentAssertion     = "true"   createDtoPrefix                 = "Create"   classValidation                 = "true" }  datasource db {   provider = "postgres"   url      = env("SERVER_AUTH_DATABASE_URL") }  model AuthUser {   id             String   @id(map: "PK_AUTH_USER") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid   externalUserId String   @unique(map: "UQ_AUTH_USER") @db.Uuid   userRole       AuthRole   timezone       Float?   /// @DtoCreateHidden   /// @DtoUpdateHidden   createdAt      DateTime @default(now()) @db.Timestamp(6)   /// @DtoCreateHidden   /// @DtoUpdateHidden   updatedAt      DateTime @default(now()) @db.Timestamp(6)   lang           String?  @db.VarChar(2)  // <--updates    @@index([userRole], map: "IDX_AUTH_USER__USER_ROLE") }  model migrations {   installed_rank Int      @id(map: "__migrations_pk")   version        String?  @db.VarChar(50)   description    String   @db.VarChar(200)   type           String   @db.VarChar(20)   script         String   @db.VarChar(1000)   checksum       Int?   installed_by   String   @db.VarChar(100)   installed_on   DateTime @default(now()) @db.Timestamp(6)   execution_time Int   success        Boolean    @@index([success], map: "__migrations_s_idx")   @@map("__migrations") }  enum AuthRole {   Admin   User }  

После успешного перезапуска генераторов во всех DTO, связанных с таблицей AuthUser, появится новое поле lang.

Обновленный файл libs/core/auth/src/lib/generated/rest/dto/auth-user.entity.ts

import { AuthRole } from '../../../../../../../../node_modules/@prisma/auth-client'; import { ApiProperty } from '@nestjs/swagger';  export class AuthUser {   @ApiProperty({     type: 'string',   })   id!: string;   @ApiProperty({     type: 'string',   })   externalUserId!: string;   @ApiProperty({     enum: AuthRole,     enumName: 'AuthRole',   })   userRole!: AuthRole;   @ApiProperty({     type: 'number',     format: 'float',     nullable: true,   })   timezone!: number | null;   @ApiProperty({     type: 'string',     format: 'date-time',   })   createdAt!: Date;   @ApiProperty({     type: 'string',     format: 'date-time',   })   updatedAt!: Date;   @ApiProperty({     type: 'string',     nullable: true,   })   lang!: string | null; // <--updates } 

3. Изменения в DTO и методы получения и обновления профиля пользователя

Чтобы обновить новое поле lang, можно создать отдельный метод либо адаптировать уже имеющиеся методы для получения и обновления профиля. В рамках данного материала мы выберем второй вариант – модификация существующих методов.

Обновляем DTO-файл libs/core/auth/src/lib/types/auth-profile.dto.ts

import { PickType } from '@nestjs/swagger'; import { CreateAuthUserDto } from '../generated/rest/dto/create-auth-user.dto';  export class AuthProfileDto extends PickType(CreateAuthUserDto, ['timezone', 'lang']) {} 

Поскольку допустимые языки ограничены определенным набором значений, необходимо проверить корректность входящих данных на сервере.

Существует несколько подходов к реализации такой проверки. В данном случае я выполню проверку внутри метода и выброшу ошибку валидации, аналогично тому, как это делает пайп валидации. Такой подход поможет унифицировать обработку ошибок полей на клиентской стороне.

Теперь обновим контроллер libs/core/auth/src/lib/controllers/auth.controller.ts.

import { StatusResponse } from '@nestjs-mod-fullstack/common'; import { ValidationError, ValidationErrorEnum } from '@nestjs-mod-fullstack/validation'; import { InjectPrismaClient } from '@nestjs-mod/prisma'; import { Body, Controller, Get, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiExtraModels, ApiOkResponse, ApiTags, refs } from '@nestjs/swagger'; import { AuthRole, PrismaClient } from '@prisma/auth-client'; import { InjectTranslateFunction, TranslateFunction, TranslatesService, TranslatesStorage } from 'nestjs-translates'; import { AUTH_FEATURE } from '../auth.constants'; import { CheckAuthRole, CurrentAuthUser } from '../auth.decorators'; import { AuthError } from '../auth.errors'; import { AuthUser } from '../generated/rest/dto/auth-user.entity'; import { AuthEntities } from '../types/auth-entities'; import { AuthProfileDto } from '../types/auth-profile.dto'; import { AuthCacheService } from '../services/auth-cache.service';  @ApiExtraModels(AuthError, AuthEntities, ValidationError) @ApiBadRequestResponse({   schema: { allOf: refs(AuthError, ValidationError) }, }) @ApiTags('Auth') @CheckAuthRole([AuthRole.User, AuthRole.Admin]) @Controller('/auth') export class AuthController {   constructor(     @InjectPrismaClient(AUTH_FEATURE)     private readonly prismaClient: PrismaClient,     private readonly authCacheService: AuthCacheService,     private readonly translatesStorage: TranslatesStorage   ) {}    @Get('profile')   @ApiOkResponse({ type: AuthProfileDto })   async profile(@CurrentAuthUser() authUser: AuthUser): Promise<AuthProfileDto> {     return {       lang: authUser.lang, // <--updates       timezone: authUser.timezone,     };   }    @Post('update-profile')   @ApiOkResponse({ type: StatusResponse })   async updateProfile(@CurrentAuthUser() authUser: AuthUser, @Body() args: AuthProfileDto, @InjectTranslateFunction() getText: TranslateFunction) {     if (args.lang && !this.translatesStorage.locales.includes(args.lang)) {       // <--updates       throw new ValidationError(undefined, ValidationErrorEnum.COMMON, [         {           property: 'lang',           constraints: {             isNotEmpty: getText('lang must have one of the values: {{values}}', this.translatesStorage.locales.join(', ')),           },         },       ]);     }     await this.prismaClient.authUser.update({       where: { id: authUser.id },       data: {         ...(args.lang === undefined // <--updates           ? {}           : {               lang: args.lang,             }),         ...(args.timezone === undefined // <--updates           ? {}           : {               timezone: args.timezone,             }),         updatedAt: new Date(),       },     });     await this.authCacheService.clearCacheByExternalUserId(authUser.externalUserId);     return { message: getText('ok') };   } } 

4. Адаптация «AuthGuard» для получения языка пользователя из базы данных

Теперь изменим поведение AuthGuard, чтобы значение языка пользователя извлекалось не из фронтенд-запроса, а из настроек, сохраненных в базе данных.

Для этого обновим файл libs/core/auth/src/lib/auth.guard.ts.

// ...  @Injectable() export class AuthGuard implements CanActivate {   private logger = new Logger(AuthGuard.name);    constructor(     // ...     private readonly translatesStorage: TranslatesStorage   ) {}    // ...    private async tryGetOrCreateCurrentUserWithExternalUserId(req: AuthRequest, externalUserId: string) {     if (!req.authUser && externalUserId) {       const authUser = await this.authCacheService.getCachedUserByExternalUserId(externalUserId);       req.authUser =         authUser ||         (await this.prismaClient.authUser.upsert({           create: { externalUserId, userRole: 'User' },           update: {},           where: { externalUserId },         }));        if (req.authUser.lang) {         req.headers[ACCEPT_LANGUAGE] = req.authUser.lang;       }     }      if (req.headers[ACCEPT_LANGUAGE] && !this.translatesStorage.locales.includes(req.headers[ACCEPT_LANGUAGE])) {       req.headers[ACCEPT_LANGUAGE] = this.translatesStorage.defaultLocale;     }   }   // ... } 

5. Обновление «SDK» для взаимодействия с бэкендом

Теперь необходимо пересоздать все SDK, обеспечивающие взаимодействие с нашим сервером.

Команды

npm run manual:prepare 

6. Разработка нового теста для бэкенда на смену и использование языка из базы данных

Для проверки корректности работы функционала создадим специальный тестовый сценарий, который подтвердит, что смена языка и его последующее извлечение из базы данных происходят без ошибок.

Создаем файл apps/server-e2e/src/server/store-lang-in-db.spec.ts

import { RestClientHelper } from '@nestjs-mod-fullstack/testing'; import { AxiosError } from 'axios';  describe('Store lang in db', () => {   jest.setTimeout(60000);    const user1 = new RestClientHelper();    beforeAll(async () => {     await user1.createAndLoginAsUser();   });    it('should catch error on try use not exists language code', async () => {     try {       await user1.getAuthApi().authControllerUpdateProfile({ lang: 'tt' });     } catch (err) {       expect((err as AxiosError).response?.data).toEqual({         code: 'VALIDATION-000',         message: 'Validation error',         metadata: [           {             property: 'lang',             constraints: [               {                 name: 'isWrongEnumValue',                 description: 'lang must have one of the values: en, ru',               },             ],           },         ],       });     }   });    it('should catch error in Russian language on create new webhook as user1', async () => {     await user1.getAuthApi().authControllerUpdateProfile({ lang: 'ru' });     try {       await user1.getWebhookApi().webhookControllerCreateOne({         enabled: false,         endpoint: '',         eventName: '',       });     } catch (err) {       expect((err as AxiosError).response?.data).toEqual({         code: 'VALIDATION-000',         message: 'Validation error',         metadata: [           {             property: 'eventName',             constraints: [               {                 name: 'isNotEmpty',                 description: 'eventName не может быть пустым',               },             ],           },           {             property: 'endpoint',             constraints: [               {                 name: 'isNotEmpty',                 description: 'endpoint не может быть пустым',               },             ],           },         ],       });     }   }); }); 

7. Выполнение всех серверных «E2E»-тестов

Запустим все тесты уровня E2E для сервера, чтобы убедиться, что весь функционал работает корректно и без сбоев.

Команды

npm run nx -- run server-e2e:e2e 

8. Создание сервиса для управления активным языком пользователя во фронтенде

Во фронтенд-приложении создадим сервис, который будет управлять активным языком как для авторизованных, так и для неавторизованных пользователей.

Логика работы с активным языком для неавторизованных пользователей останется прежней: она будет использовать localStorage.

Однако, после авторизации активный язык будет сохраняться в профиле пользователя.

Создаем файл libs/core/auth-angular/src/lib/services/auth-active-lang.service.ts

import { Injectable } from '@angular/core'; import { TranslocoService } from '@jsverse/transloco'; import { AuthErrorEnumInterface, AuthErrorInterface, AuthRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { catchError, map, of, tap, throwError } from 'rxjs';  const AUTH_ACTIVE_LANG_LOCAL_STORAGE_KEY = 'activeLang';  @Injectable({ providedIn: 'root' }) export class AuthActiveLangService {   constructor(private readonly authRestService: AuthRestService, private readonly translocoService: TranslocoService) {}    getActiveLang() {     return this.authRestService.authControllerProfile().pipe(       map((profile) => {         return profile.lang || this.translocoService.getDefaultLang();       }),       catchError((err) => {         if ('error' in err && (err.error as AuthErrorInterface).code === AuthErrorEnumInterface._001) {           return of(localStorage.getItem(AUTH_ACTIVE_LANG_LOCAL_STORAGE_KEY) || this.translocoService.getDefaultLang());         }         return throwError(() => err);       })     );   }    setActiveLang(lang: string) {     return this.authRestService.authControllerUpdateProfile({ lang }).pipe(       tap(() => {         this.translocoService.setActiveLang(lang);       }),       catchError((err) => {         if ('error' in err && (err.error as AuthErrorInterface).code === AuthErrorEnumInterface._001) {           localStorage.setItem(AUTH_ACTIVE_LANG_LOCAL_STORAGE_KEY, lang);           this.translocoService.setActiveLang(lang);           return of(null);         }         return throwError(() => err);       })     );   } } 

Теперь заменим все случаи использования localStorage для хранения языка на AuthActiveLangService по всему коду фронтенда.

9. Разработка нового теста для фронтенда на смену и использование языка из базы данных

В рамках теста мы выполним следующие шаги: зарегистрируемся, сменим язык на русский, затем изменим язык в localStorage с русского на английский и попробуем создать новый веб-хук с пустыми полями. Ожидаемый результат — получение ошибки валидации на русском языке.

Создаем файл apps/client-e2e/src/ru-validation-with-store-lang-in-db.spec.ts.

import { faker } from '@faker-js/faker'; import { expect, Page, test } from '@playwright/test'; import { get } from 'env-var'; import { join } from 'path'; import { setTimeout } from 'timers/promises';  test.describe('Validation with store lang in db (ru)', () => {   test.describe.configure({ mode: 'serial' });    const user = {     email: faker.internet.email({       provider: 'example.fakerjs.dev',     }),     password: faker.internet.password({ length: 8 }),     site: `http://${faker.internet.domainName()}`,   };   let page: Page;    test.beforeAll(async ({ browser }) => {     page = await browser.newPage({       viewport: { width: 1920, height: 1080 },       recordVideo: {         dir: join(__dirname, 'video'),         size: { width: 1920, height: 1080 },       },     });     await page.goto('/', {       timeout: 7000,     });     await page.evaluate((authorizerURL) => localStorage.setItem('authorizerURL', authorizerURL), get('SERVER_AUTHORIZER_URL').required().asString());     await page.evaluate((minioURL) => localStorage.setItem('minioURL', minioURL), get('SERVER_MINIO_URL').required().asString());   });    test.afterAll(async () => {     await setTimeout(1000);     await page.close();   });    test('sign up as user', async () => {     await page.goto('/sign-up', {       timeout: 7000,     });      await page.locator('auth-sign-up-form').locator('[placeholder=email]').click();     await page.keyboard.type(user.email.toLowerCase(), {       delay: 50,     });     await expect(page.locator('auth-sign-up-form').locator('[placeholder=email]')).toHaveValue(user.email.toLowerCase());      await page.locator('auth-sign-up-form').locator('[placeholder=password]').click();     await page.keyboard.type(user.password, {       delay: 50,     });     await expect(page.locator('auth-sign-up-form').locator('[placeholder=password]')).toHaveValue(user.password);      await page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]').click();     await page.keyboard.type(user.password, {       delay: 50,     });     await expect(page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]')).toHaveValue(user.password);      await expect(page.locator('auth-sign-up-form').locator('button[type=submit]')).toHaveText('Sign-up');      await page.locator('auth-sign-up-form').locator('button[type=submit]').click();      await setTimeout(5000);      await expect(page.locator('nz-header').locator('[nz-submenu]').first()).toContainText(`You are logged in as ${user.email.toLowerCase()}`);   });    test('should change language to RU', async () => {     await expect(page.locator('nz-header').locator('[nz-submenu]').last()).toContainText(`EN`);     await page.locator('nz-header').locator('[nz-submenu]').last().click();      await expect(page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last()).toContainText(`Russian`);      await page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last().click();      await setTimeout(4000);     //      await expect(page.locator('nz-header').locator('[nz-submenu]').last()).toContainText(`RU`);   });    test('change lang to en in localStorage', async () => {     await page.evaluate(() => localStorage.setItem('activeLang', 'en'));      const activeLang = await page.evaluate(() => localStorage.getItem('activeLang'));      expect(activeLang).toEqual('en');   });    test('should catch error on create new webhook', async () => {     await page.locator('webhook-grid').locator('button').first().click();      await setTimeout(7000);      await page.locator('[nz-modal-footer]').locator('button').last().click();      await setTimeout(4000);      await expect(page.locator('webhook-form').locator('formly-validation-message').first()).toContainText('поле "адрес" не может быть пустым');     await expect(page.locator('webhook-form').locator('formly-validation-message').last()).toContainText('поле "событие" не может быть пустым');   }); }); 

10. Выполнение всех тестов уровня E2E для сервера и клиента

Запустим все тесты уровня E2E как для сервера, так и для клиента, чтобы удостовериться, что вся функциональность работает корректно и без ошибок.

Команды

npm run pm2-full:dev:test:e2e 

Заключение

Несмотря на кажущуюся простоту задачи, её решение потребовало значительного количества времени и написания немалого объема кода.

Однако, даже для таких минимальных изменений крайне важно обеспечить покрытие E2E-тестами, что и было продемонстрировано в этом посте.

Планы

В предыдущей статье я внедрил функцию автоматической конвертации выходных данных, содержащих поля типа Date или строки в формате ISOString. Тем не менее, я еще не реализовал автоматическую конвертацию входных данных. В следующем посте я займусь именно этим вопросом.

Ссылки


ссылка на оригинал статьи https://habr.com/ru/articles/866858/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *