Конвертация даты по временной зоне пользователя в "NestJS", а также ввод и отображение даты в "Angular"
В этой статье я расскажу о добавл ении нового поля workUntilDate
с типом timestamp(6)
в таблицу Webhook
базы данных Webhook
.
На стороне фронтенда (в Angular
-приложении) для этого поля будет реализован удобный календарь с возможностью выбора времени.
Пользователи смогут задавать дату и время в своей временной зоне, тогда как бэкенд (NestJS
-приложение) будет сохранять введённые данные в базе данных в формате UTC+0
.
Кроме того, интерфейс календаря и другие элементы, отображающие даты, будут адаптированы под язык и временную зону пользователя.
1. Установка необходимых библиотек
Для начала установим требуемые пакеты:
Команды
npm install --save @jsverse/transloco-locale @jsverse/transloco-messageformat --prefer-offline --no-audit --progress=false
2. Создание миграции
Мои миграции написаны таким образом, чтобы их можно было запускать повторно.
Это полезно в тех случаях, когда требуется отменить применение миграции и запустить её заново.
Команды
npm run flyway:create:webhook --args=AddFieldWorkUntilDateToAuthUser
Обновляем файл libs/feature/webhook/src/migrations/V202412200905__AddFieldWorkUntilDateToAuthUser.sql
DO $$
BEGIN
ALTER TABLE "Webhook"
ADD "workUntilDate" timestamp(6);
EXCEPTION
WHEN duplicate_column THEN
NULL;
END
$$;
3. Применение миграции и обновление "Prisma"-схем
Теперь применим созданную миграцию, пересоздадим схемы Prisma
и запустим Prisma
-генераторы.
Команды
npm run docker-compose:start-prod:server
npm run db:create-and-fill
npm run prisma:pull
npm run generate
После выполнения этих шагов, во всех соответствующих DTO
появится новое поле workUntilDate
.
Пример обновления DTO
-файла libs/feature/webhook/src/lib/generated/rest/dto/webhook.dto.ts
import { Prisma } from '../../../../../../../../node_modules/@prisma/webhook-client';
import { ApiProperty } from '@nestjs/swagger';
export class WebhookDto {
// ...
// updates
@ApiProperty({
type: 'string',
format: 'date-time',
nullable: true,
})
workUntilDate!: Date | null;
}
Пример обновления Prisma
-схемы libs/feature/webhook/src/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
engineType = "binary"
output = "../../../../../node_modules/@prisma/webhook-client"
binaryTargets = ["native","linux-musl","debian-openssl-1.1.x","linux-musl-openssl-3.0.x"]
}
// ...
model Webhook {
id String @id(map: "PK_WEBHOOK") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
// ...
workUntilDate DateTime? @db.Timestamp(6) /// <-- updates
}
4. Использование "AsyncLocalStorage" для хранения текущей временной зоны пользователя
Ранее мы применяли AuthTimezoneInterceptor
для преобразования выходных данных с датами в формате UTC-0
в формат с учетом временной зоны пользователя.
Преобразование входящей даты из временной зоны пользователя в дату в формате UTC-0
, в котором она хранится в базе данных, осуществляется в AuthTimezonePipe
.
Однако в этом контексте у нас отсутствует доступ к данным запроса, поэтому невозможно определить пользователя и его временную зону.
Чтобы решить эту проблему, мы обернем каждый входящий запрос в AsyncLocalStorage
, что позволит получать информацию о временной зоне пользователя.
Обновляем файл libs/core/auth/src/lib/interceptors/auth-timezone.interceptor.ts
// ...
import { AsyncLocalStorage } from 'node:async_hooks';
import { AuthAsyncLocalStorageData } from '../types/auth-async-local-storage-data';
@Injectable()
export class AuthTimezoneInterceptor implements NestInterceptor<TData, TData> {
constructor(
// ...
private readonly asyncLocalStorage: AsyncLocalStorage<AuthAsyncLocalStorageData>
) {}
intercept(context: ExecutionContext, next: CallHandler) {
const req: AuthRequest = getRequestFromExecutionContext(context);
const userId = req.authUser?.externalUserId;
if (!this.authEnvironments.useInterceptors) {
return next.handle();
}
if (!userId) {
return next.handle();
}
const run = () => {
const result = next.handle();
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(data)) {
return data.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>;
};
if (!this.authEnvironments.usePipes) {
return run();
}
return this.asyncLocalStorage.run({ authTimezone: req.authUser?.timezone || 0 }, () => run());
}
}
5. Создание "Pipe" для преобразования входного объекта
Мы реализуем Pipe
, который будет вычитать временную зону пользователя из всех полей входящего объекта, содержащих строки с датами.
Если временная зона самого бэкенд-сервера отличается от UTC-0
, то отнимаем разницу.
Обновляем файл libs/core/auth/src/lib/pipes/auth-timezone.pipe.ts
import { SERVER_TIMEZONE_OFFSET } from '@nestjs-mod-fullstack/common';
import { Injectable, PipeTransform } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
import { AuthEnvironments } from '../auth.environments';
import { AuthTimezoneService } from '../services/auth-timezone.service';
import { AuthAsyncLocalStorageData } from '../types/auth-async-local-storage-data';
@Injectable()
export class AuthTimezonePipe implements PipeTransform {
constructor(private readonly asyncLocalStorage: AsyncLocalStorage<AuthAsyncLocalStorageData>, private readonly authTimezoneService: AuthTimezoneService, private readonly authEnvironments: AuthEnvironments) {}
transform(value: unknown) {
if (!this.authEnvironments.usePipes) {
return value;
}
const result = this.authTimezoneService.convertObject(value, -1 * (this.asyncLocalStorage.getStore()?.authTimezone || 0) - SERVER_TIMEZONE_OFFSET);
return result;
}
}
6. Регистрация интерцептора и сервиса для хранения асинхронного состояния в модуле авторизации
Теперь добавим созданный интерцептор и сервис для хранения асинхронного состояния в модуль авторизации.