Поддержка временных зон в фулстек-приложении на основе 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') };
}
}
8. Создаем сервис для рекурсивного преобразования полей типа "Date" в заданную временную зону
Разработаем сервис, который будет выполнять рекурсивное преобразование полей типа "Date" в указанную временную зону.
Создаем файл libs/core/auth/src/lib/services/auth-timezone.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { addHours } from 'date-fns';
export type TObject = Record<string, unknown>;
export type TData = unknown | unknown[] | TObject | TObject[];
@Injectable()
export class AuthTimezoneService {
private logger = new Logger(AuthTimezoneService.name);
convertObject(data: TData, timezone: number | null | undefined, depth = 10): TData {
if (depth === 0) {
return data;
}
if (Array.isArray(data)) {
const newArray: unknown[] = [];
for (const item of data) {
newArray.push(this.convertObject(item, timezone, depth - 1));
}
return newArray;
}
if ((typeof data === 'string' || typeof data === 'number' || typeof data === 'function') && !this.isValidStringDate(data) && !this.isValidDate(data)) {
return data;
}
try {
if (data && timezone) {
if (this.isValidStringDate(data) || this.isValidDate(data)) {
if (this.isValidStringDate(data) && typeof data === 'string') {
data = new Date(data);
}
data = addHours(data as Date, timezone);
} else {
const keys = Object.keys(data);
for (const key of keys) {
(data as TObject)[key] = this.convertObject((data as TObject)[key], timezone, depth - 1);
}
}
}
} catch (err: unknown) {
if (err instanceof Error) {
this.logger.error(err, err.stack);
}
}
return data;
}
private isValidStringDate(data: string | number | unknown) {
return typeof data === 'string' && data.length === '0000-00-00T00:00:00.000Z'.length && !isNaN(+new Date(data));
}
private isValidDate(data: string | number | Date | object | unknown) {
if (data && typeof data === 'object') {
return !isNaN(+data);
}
return typeof data === 'string' && !isNaN(+new Date(data));
}
}
9. Добавляем интерцептор для автоматической коррекции времени в данных
Создадим интерцептор, который будет автоматически конвертировать временные значения в данных в соответствии с выбранной пользователем временной зоной. Это гарантирует корректное отображение дат и времени в пользовательском интерфейсе.
Создаем файл libs/core/auth/src/lib/interceptors/auth-timezone.interceptor.ts
import { getRequestFromExecutionContext } from '@nestjs-mod/common';
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { isObservable, Observable } from 'rxjs';
import { concatMap } from 'rxjs/operators';
import { AuthCacheService } from '../services/auth-cache.service';
import { AuthTimezoneService, TData } from '../services/auth-timezone.service';
import { AuthRequest } from '../types/auth-request';
import { AuthEnvironments } from '../auth.environments';
@Injectable()
export class AuthTimezoneInterceptor implements NestInterceptor<TData, TData> {
constructor(private readonly authTimezoneService: AuthTimezoneService, private readonly authCacheService: AuthCacheService, private readonly authEnvironments: AuthEnvironments) {}
intercept(context: ExecutionContext, next: CallHandler) {
const result = next.handle();
if (!this.authEnvironments.useInterceptors) {
return result;
}
const req: AuthRequest = getRequestFromExecutionContext(context);
const userId = req.authUser?.externalUserId;
if (!userId) {
return result;
}
if (isObservable(result)) {
return result.pipe(
concatMap(async (data) => {
const user = await this.authCacheService.getCachedUserByExternalUserId(userId);
return this.authTimezoneService.convertObject(data, user?.timezone);
})
);
}
if (result instanceof Promise && typeof result?.then === 'function') {
return result.then(async (data) => {
if (isObservable(result)) {
return result.pipe(
concatMap(async (data) => {
const user = await this.authCacheService.getCachedUserByExternalUserId(userId);
return this.authTimezoneService.convertObject(data, user?.timezone);
})
);
} else {
const user = await this.authCacheService.getCachedUserByExternalUserId(userId);
// need for correct map types with base method of NestInterceptor
return this.authTimezoneService.convertObject(data, user?.timezone) as Observable<TData>;
}
});
}
// need for correct map types with base method of NestInterceptor
return this.authTimezoneService.convertObject(result, req.authUser?.timezone) as Observable<TData>;
}
}
10. Добавляем "AuthGuard" для автоматического создания пользователей в базе данных "Auth"
Интегрируем "AuthGuard", чтобы пользователи могли автоматически регистрироваться в базе данных "Auth" при работе с системой.
Создаем файл libs/core/auth/src/lib/auth.module.ts
import { AllowEmptyUser } from '@nestjs-mod/authorizer';
import { getRequestFromExecutionContext } from '@nestjs-mod/common';
import { InjectPrismaClient } from '@nestjs-mod/prisma';
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthRole, PrismaClient } from '@prisma/auth-client';
import { AUTH_FEATURE } from './auth.constants';
import { CheckAuthRole, SkipAuthGuard } from './auth.decorators';
import { AuthError, AuthErrorEnum } from './auth.errors';
import { AuthCacheService } from './services/auth-cache.service';
import { AuthRequest } from './types/auth-request';
import { AuthEnvironments } from './auth.environments';
@Injectable()
export class AuthGuard implements CanActivate {
private logger = new Logger(AuthGuard.name);
constructor(
@InjectPrismaClient(AUTH_FEATURE)
private readonly prismaClient: PrismaClient,
private readonly reflector: Reflector,
private readonly authCacheService: AuthCacheService,
private readonly authEnvironments: AuthEnvironments
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.authEnvironments.useGuards) {
return true;
}
try {
const { skipAuthGuard, checkAuthRole, allowEmptyUserMetadata } = this.getHandlersReflectMetadata(context);
if (skipAuthGuard) {
return true;
}
const req: AuthRequest = this.getRequestFromExecutionContext(context);
if (req.authorizerUser?.id) {
await this.tryGetOrCreateCurrentUserWithExternalUserId(req, req.authorizerUser.id);
}
this.throwErrorIfCurrentUserNotSet(req, allowEmptyUserMetadata);
this.throwErrorIfCurrentUserNotHaveNeededRoles(checkAuthRole, req);
} catch (err) {
this.logger.error(err, (err as Error).stack);
throw err;
}
return true;
}
private throwErrorIfCurrentUserNotHaveNeededRoles(checkAuthRole: AuthRole[] | undefined, req: AuthRequest) {
if (checkAuthRole && req.authUser && !checkAuthRole?.includes(req.authUser.userRole)) {
throw new AuthError(AuthErrorEnum.FORBIDDEN);
}
}
private throwErrorIfCurrentUserNotSet(req: AuthRequest, allowEmptyUserMetadata?: boolean) {
if (!req.skippedByAuthorizer && !req.authUser && !allowEmptyUserMetadata) {
throw new AuthError(AuthErrorEnum.USER_NOT_FOUND);
}
}
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 },
}));
}
}
private getRequestFromExecutionContext(context: ExecutionContext) {
const req = getRequestFromExecutionContext(context) as AuthRequest;
req.headers = req.headers || {};
return req;
}
private getHandlersReflectMetadata(context: ExecutionContext) {
const allowEmptyUserMetadata = Boolean((typeof context.getHandler === 'function' && this.reflector.get(AllowEmptyUser, context.getHandler())) || (typeof context.getClass === 'function' && this.reflector.get(AllowEmptyUser, context.getClass())) || undefined);
const skipAuthGuard = (typeof context.getHandler === 'function' && this.reflector.get(SkipAuthGuard, context.getHandler())) || (typeof context.getClass === 'function' && this.reflector.get(SkipAuthGuard, context.getClass())) || undefined;
const checkAuthRole = (typeof context.getHandler === 'function' && this.reflector.get(CheckAuthRole, context.getHandler())) || (typeof context.getClass === 'function' && this.reflector.get(CheckAuthRole, context.getClass())) || undefined;
return { allowEmptyUserMetadata, skipAuthGuard, checkAuthRole };
}
}