Интеграция и сохранение выбранного языка пользователя в базу данных в фулстек-приложении на "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') };
}
}