Поддержка временных зон в фулстек-приложении на основе NestJS и Angular: работа с REST и WebSockets
В этой статье я хотел бы поделиться своим опытом по внедрению поддержки временных зон в фулстек-приложение, построенное на NestJS
и Angular
. Мы узнаем, как сохранить настройки таймзоны пользователя в базе данных и правильно использовать их при взаимодействии с сервером через REST
и веб-сокеты.
1. Устанавливаем все необходимые библиотеки
Установим библиотеку date-fns
, которая необходима для работы с датами и временными зонами.
Команды
npm install --save date-fns
2. Добавляем поддержку Prisma и миграций от Flyway в модуль авторизации
Подключим модули Prisma
и Flyway
в файл main.ts
, чтобы настроить взаимодействие с новой базой данных Auth
.
Обновляем файл apps/server/src/main.ts
import { AUTH_FEATURE, AUTH_FOLDER, AuthModule } from '@nestjs-mod-fullstack/auth';
// ...
bootstrapNestApplication({
modules: {
// ...
core: [
// ...
PrismaModule.forRoot({
contextName: AUTH_FEATURE,
staticConfiguration: {
featureName: AUTH_FEATURE,
schemaFile: join(rootFolder, AUTH_FOLDER, 'src', 'prisma', PRISMA_SCHEMA_FILE),
prismaModule: isInfrastructureMode() ? import(`@nestjs-mod/prisma`) : import(`@nestjs-mod/prisma`),
addMigrationScripts: false,
nxProjectJsonFile: join(rootFolder, AUTH_FOLDER, PROJECT_JSON_FILE),
},
}),
],
infrastructure: [
// ...
DockerComposePostgreSQL.forFeatureAsync({
featureModuleName: AUTH_FEATURE,
featureConfiguration: {
nxProjectJsonFile: join(rootFolder, AUTH_FOLDER, PROJECT_JSON_FILE),
},
}),
Flyway.forRoot({
staticConfiguration: {
featureName: AUTH_FEATURE,
migrationsFolder: join(rootFolder, AUTH_FOLDER, 'src', 'migrations'),
configFile: join(rootFolder, FLYWAY_JS_CONFIG_FILE),
nxProjectJsonFile: join(rootFolder, AUTH_FOLDER, PROJECT_JSON_FILE),
},
}),
],
},
});
Генерируем дополнительный код по инфраструктуре.
Команды
npm run docs:infrastructure
Добавляем новую переменную окружения с логином и паролем для подключения к новой базе данных.
Обновляем файлы .env и example.env
SERVER_AUTH_DATABASE_URL=postgres://auth:auth_password@localhost:5432/auth?schema=public
3. Создание таблицы для хранения временной зоны пользователя
Для хранения данных о временных зонах пользователей я предпочёл использовать модуль авторизации Auth
, что обусловлено ар хитектурными особенностями нашего проекта. В иных ситуациях можно было бы рассмотреть создание отдельного поля в базе данных Accounts
или даже специального модуля TimezoneModule
для управления задачами, связанными с временными зонами.
Теперь создадим миграцию для формирования всех нужных таблиц в базе данных Auth
.
Команды
# Create migrations folder
mkdir -p ./libs/core/auth/src/migrations
# Create empty migration
npm run flyway:create:auth --args=Init
Заполняем файл миграции SQL-скриптами для создания необходимых таблиц и индексов.
Обновляем файл libs/core/auth/src/migrations/V202412071217__Init.sql
DO $$
BEGIN
CREATE TYPE "AuthRole" AS enum(
'Admin',
'User'
);
EXCEPTION
WHEN duplicate_object THEN
NULL;
END
$$;
CREATE TABLE IF NOT EXISTS "AuthUser"(
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"externalUserId" uuid NOT NULL,
"userRole" "AuthRole" NOT NULL,
"timezone" double precision,
"createdAt" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PK_AUTH_USER" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_AUTH_USER" ON "AuthUser"("externalUserId");
CREATE INDEX IF NOT EXISTS "IDX_AUTH_USER__USER_ROLE" ON "AuthUser"("userRole");
Теперь база данных Auth
будет содержать таблицу AuthUser
, в которой будет храниться информация о временной зоне каждого пользователя.
Применяем созданные миграции и пересоздаем Prisma
-схемы для всех баз данных.
Команды
npm run docker-compose:start-prod:server
npm run db:create-and-fill
npm run prisma:pull
Файл схемы для новой базы данных libs/core/auth/src/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../../../../../node_modules/@prisma/auth-client"
engineType = "binary"
}
datasource db {
provider = "postgresql"
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?
createdAt DateTime @default(now()) @db.Timestamp(6)
updatedAt DateTime @default(now()) @db.Timestamp(6)
@@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
}
4. Генерация "DTO" для новой базы данных "Auth"
Подключаем генератор DTO
к Prisma
-схеме и исключаем некоторые поля из процесса генерации.
Обновляем файл libs/core/auth/src/prisma/schema.prisma
// ...
generator prismaClassGenerator {
provider = "prisma-generator-nestjs-dto"
output = "../lib/generated/rest/dto"
updateDtoPrefix = "Update"
entityPrefix = ""
entitySuffix = ""
definiteAssignmentAssertion = "true"
flatResourceStructure = "false"
exportRelationModifierClasses = "true"
fileNamingStyle = "kebab"
createDtoPrefix = "Create"
classValidation = "true"
noDependencies = "false"
outputToNestJsResourceStructure = "false"
annotateAllDtoProperties = "true"
dtoSuffix = "Dto"
reExport = "false"
prettier = "true"
}
// ...
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)
@@index([userRole], map: "IDX_AUTH_USER__USER_ROLE")
}
// ...
Перезапускаем генераторы для всех баз данных.
Команды
npm run prisma:generate
После успешного выполнения команды мы получаем новые файлы в папке libs/core/auth/src/lib/generated/rest/dto
:
auth-user.dto.ts
connect-auth-user.dto.ts
create-auth-user.dto.ts
migrations.dto.ts
update-auth-user.dto.ts
auth-user.entity.ts
connect-migrations.dto.ts
create-migrations.dto.ts
migrations.entity.ts
update-migrations.dto.ts
Поскольку сгенерированные файлы могут содержать ошибки форматирования, которые выявляет eslint
, мы исключаем эти файлы из проверки eslint
.
Обновляем файлы .eslintignore
...
libs/core/auth/src/lib/generated/rest/dto
5. Обновляем параметры импорта модуля PrismaModule
для базы данных Auth
Изменяем конфигурацию импорта модуля PrismaModule
для базы данных Auth
, чтобы учесть новые требования к взаимодействию с базой данных.
Обновляем файл apps/server/src/main.ts
// ...
bootstrapNestApplication({
modules: {
// ...
core: [
// ...
PrismaModule.forRoot({
contextName: AUTH_FEATURE,
staticConfiguration: {
featureName: AUTH_FEATURE,
schemaFile: join(rootFolder, AUTH_FOLDER, 'src', 'prisma', PRISMA_SCHEMA_FILE),
prismaModule: isInfrastructureMode() ? import(`@nestjs-mod/prisma`) : import(`@prisma/auth-client`),
addMigrationScripts: false,
nxProjectJsonFile: join(rootFolder, AUTH_FOLDER, PROJECT_JSON_FILE),
},
}),
],
// ...
},
});
6. Создаем сервис кэширования для пользователей базы данных Auth
Создаем сервис для кэширования пользователей базы данных Auth
, чтобы ускорить доступ к данным из сервисов AuthGuard
и AuthTimezoneInterceptor
.
Создаем файл libs\core\auth\src\lib\services\auth-cache.service.ts
import { CacheManagerService } from '@nestjs-mod/cache-manager';
import { InjectPrismaClient } from '@nestjs-mod/prisma';
import { Injectable } from '@nestjs/common';
import { AuthUser, PrismaClient } from '@prisma/auth-client';
import { AUTH_FEATURE } from '../auth.constants';
import { AuthEnvironments } from '../auth.environments';
@Injectable()
export class AuthCacheService {
constructor(
@InjectPrismaClient(AUTH_FEATURE)
private readonly prismaClient: PrismaClient,
private readonly cacheManagerService: CacheManagerService,
private readonly authEnvironments: AuthEnvironments
) {}
async clearCacheByExternalUserId(externalUserId: string) {
const authUsers = await this.prismaClient.authUser.findMany({
where: { externalUserId },
});
for (const authUser of authUsers) {
await this.cacheManagerService.del(this.getUserCacheKey(authUser));
}
}
async getCachedUserByExternalUserId(externalUserId: string) {
const cached = await this.cacheManagerService.get<AuthUser | null>(
this.getUserCacheKey({
externalUserId,
})
);
if (cached) {
return cached;
}
const user = await this.prismaClient.authUser.findFirst({
where: {
externalUserId,
},
});
if (user) {
await this.cacheManagerService.set(this.getUserCacheKey({ externalUserId }), user, this.authEnvironments.cacheTTL);
return user;
}
return null;
}
private getUserCacheKey({ externalUserId }: { externalUserId: string }): string {
return `authUser.${externalUserId}`;
}
}
7. Разработка контроллера для работы с информацией о временной зоне пользователя
Создадим контроллер, который будет отвечать за получение текущих настроек временной зоны пользователя и обновление этих параметров при необходимости.
Создаем файл libs/core/auth/src/lib/controllers/auth.controller.ts
import { StatusResponse } from '@nestjs-mod-fullstack/common';
import { ValidationError } 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 } 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
) {}
@Get('profile')
@ApiOkResponse({ type: AuthProfileDto })
async profile(@CurrentAuthUser() authUser: AuthUser): Promise<AuthProfileDto> {
return { timezone: authUser.timezone };
}
@Post('update-profile')
@ApiOkResponse({ type: StatusResponse })
async updateProfile(@CurrentAuthUser() authUser: AuthUser, @Body() args: AuthProfileDto, @InjectTranslateFunction() getText: TranslateFunction) {
await this.prismaClient.authUser.update({
where: { id: authUser.id },
data: {
timezone: args.timezone,
updatedAt: new Date(),
},
});
await this.authCacheService.clearCacheByExternalUserId(authUser.externalUserId);
return { message: getText('ok') };
}
}