Интеграция и сохранение выбранного языка пользователя в базу данных в фулстек-приложении на "Angular" и "NestJS"
Этот пост не претендует на масштабность, но поскольку я последовательно документирую все этапы разработки бойлерплейта в формате статей, решил описать и эту задачу.
Здесь я приведу пример миграции базы данных для добавления нового поля, а также покажу, как реализовать соответствующий функционал на бэкенде и фронтенде для изменения этого значения.
Язык пользователя, как и временная зона, будет храниться в базе данных 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://nestjs.com - официальный сайт фреймворка
- https://nestjs-mod.com - официальный сайт дополнительных утилит
- https://fullstack.nestjs-mod.com - сайт из поста
- https://github.com/nestjs-mod/nestjs-mod-fullstack - проект из поста
- https://github.com/nestjs-mod/nestjs-mod-fullstack/compare/3019d982ca9605479a8b917f71a8ae268f3582bc..4f495dbd6b9b4efd8d8e13a60c5f66b895c483af - изменения
- https://github.com/nestjs-mod/nestjs-mod-fullstack/actions/runs/12347665539/artifacts/2324579763 - видео с E2E-тестов фронтенда
#angular #translates #nestjsmod #fullstack #2024-12-16