Конвертация даты по временной зоне пользователя в "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. Регистрация интерцептора и сервиса для хранения асинхронного состояния в модуле авторизации
Теперь добавим созданный интерцептор и сервис для хранения асинхронного состояния в модуль авторизации.
Обновляем файл libs/core/auth/src/lib/auth.module.ts
// ...
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
// ...
import { AsyncLocalStorage } from 'node:async_hooks';
import { AuthTimezonePipe } from './pipes/auth-timezone.pipe';
export const { AuthModule } = createNestModule({
// ...
sharedProviders: [
{
provide: AsyncLocalStorage,
useValue: new AsyncLocalStorage(),
},
AuthTimezoneService,
AuthCacheService,
],
providers: [
// ...
{ provide: APP_PIPE, useClass: AuthTimezonePipe },
AuthAuthorizerService,
AuthAuthorizerBootstrapService,
],
// ...
});
7. Добавление нового типа поля "date-input" для "Formly"
Несмотря на то, что стандартное HTML
-поле ввода поддерживает ввод и отображение данных с типом Date
, его внешний вид отличается от компонентов, предоставляемых ng.ant.design
.
Чтобы сохранить единообразие интерфейса, мы создадим новый контрол date-input
для Formly
.
Создаем файл libs/common-angular/src/lib/formly/date-input.component.ts
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { TranslocoService } from '@jsverse/transloco';
import { FieldType, FieldTypeConfig, FormlyModule } from '@ngx-formly/core';
import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
import { map, Observable } from 'rxjs';
import { DATE_INPUT_FORMATS } from '../constants/date-input-formats';
import { ActiveLangService } from '../services/active-lang.service';
@Component({
selector: 'date-input',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, FormlyModule, NzDatePickerModule, AsyncPipe],
template: ` <nz-date-picker [formControl]="formControl" [formlyAttributes]="field" [nzShowTime]="true" [nzFormat]="(format$ | async)!_</nz-date-picker> `,
})
export class DateInputComponent extends FieldType<FieldTypeConfig> {
format$: Observable<string>;
constructor(private readonly translocoService: TranslocoService, private readonly activeLangService: ActiveLangService) {
super();
this.format$ = translocoService.langChanges$.pipe(
map((lang) => {
const { locale } = this.activeLangService.normalizeLangKey(lang);
return DATE_INPUT_FORMATS[locale] ? DATE_INPUT_FORMATS[locale] : DATE_INPUT_FORMATS['en-US'];
})
);
}
}
Календарь теперь корректно отображает кнопки на выбранной локализации, однако содержимое самого поля ввода остаётся неизменным.
Чтобы решить эту проблему, создадим список основных локалей и форматов вывода и настариваем установку формата в качестве вывода даты в input
.
Создаем файл libs/common-angular/src/lib/constants/date-input-formats.ts
export const DATE_INPUT_FORMATS = {
'en-US': 'MM/dd/yyyy HH:mm:ss',
'en-GB': 'dd/MM/yyyy HH:mm:ss',
'ar-SA': 'dd/MM/yyyy هه:sس',
'bg-BG': 'd.M.yyyy H:m:s ч.',
'ca-ES': 'dd/MM/yyyy H:mm:ss',
'cs-CZ': 'd.M.yyyy H:mm:ss',
'da-DK': 'dd-MM-yyyy HH:mm:ss',
'de-DE': 'dd.MM.yyyy HH:mm:ss',
'el-GR': 'd/M/yyyy h:mm:ss πμ|μμ',
'es-MX': 'dd/MM/yyyy H:mm:ss',
'fi-FI': 'd.M.yyyy klo H.mm.ss',
'fr-FR': 'dd/MM/yyyy HH:mm:ss',
'he-IL': 'dd/MM/yyyy HH:mm:ss',
'hi-IN': 'dd-MM-yyyy hh:mm:ss बजे',
'hr-HR': 'd.M.yyyy. H:mm:ss',
'hu-HU': 'yyyy.MM.dd. H:mm:ss',
'id-ID': 'dd/MM/yyyy HH:mm:ss',
'is-IS': 'd.M.yyyy kl. HH:mm:ss',
'it-IT': 'dd/MM/yyyy HH:mm:ss',
'ja-JP': 'yyyy/MM/dd HH:mm:ss',
'ko-KR': 'yyyy년 MM월 dd일 HH시 mm분 ss초',
'lt-LT': 'yyyy.MM.dd. HH:mm:ss',
'lv-LV': 'yyyy.gada MM.mēnesis dd.diena HH:mm:ss',
'ms-MY': 'dd/MM/yyyy HH:mm:ss',
'nl-NL': 'dd-MM-yyyy HH:mm:ss',
'no-NO': 'dd.MM.yyyy HH:mm:ss',
'pl-PL': 'dd.MM.yyyy HH:mm:ss',
'pt-BR': 'dd/MM/yyyy HH:mm:ss',
'ro-RO': 'dd.MM.yyyy HH:mm:ss',
'ru-RU': 'dd.MM.yyyy HH:mm:ss',
'sk-SK': 'd. M. yyyy H:mm:ss',
'sl-SI': 'd.M.yyyy H:mm:ss',
'sr-RS': 'dd.MM.yyyy. HH:mm:ss',
'sv-SE': 'yyyy-MM-dd HH:mm:ss',
'th-TH': 'วันที่ d เดือน M ปี yyyy เวลา H:mm:ss',
'tr-TR': 'dd.MM.yyyy HH:mm:ss',
'uk-UA': 'dd.MM.yyyy HH:mm:ss',
'vi-VN': 'dd/MM/yyyy HH:mm:ss',
'zh-CN': 'yyyy年MM月dd日 HH时mm分ss秒',
'zh-TW': 'yyyy年MM月dd日 HH時mm分ss秒',
};
Определим новые типы в переменной, которую впоследствии подключим в конфигурации приложения.
Создаем файл libs/common-angular/src/lib/formly/formly-fields.ts
import { TypeOption } from '@ngx-formly/core/lib/models';
import { DateInputComponent } from './date-input.component';
export const COMMON_FORMLY_FIELDS: TypeOption[] = [
{
name: 'date-input',
component: DateInputComponent,
extends: 'input',
},
];
8. Разработка сервиса для смены локали в различных компонентах фронтенд-приложения
Поскольку разные компоненты используют свои уникальные механизмы для смены языка, мы объединим их в единый сервис и метод.
Создаем файл libs/common-angular/src/lib/services/active-lang.service.ts
import { Inject, Injectable } from '@angular/core';
import { toCamelCase, TranslocoService } from '@jsverse/transloco';
import { LangToLocaleMapping, TRANSLOCO_LOCALE_LANG_MAPPING, TranslocoLocaleService } from '@jsverse/transloco-locale';
import * as dateFnsLocales from 'date-fns/locale';
import * as ngZorroLocales from 'ng-zorro-antd/i18n';
import { NzI18nService } from 'ng-zorro-antd/i18n';
@Injectable({ providedIn: 'root' })
export class ActiveLangService {
constructor(
private readonly translocoService: TranslocoService,
private readonly translocoLocaleService: TranslocoLocaleService,
private readonly nzI18nService: NzI18nService,
@Inject(TRANSLOCO_LOCALE_LANG_MAPPING)
readonly langToLocaleMapping: LangToLocaleMapping
) {}
applyActiveLang(lang: string) {
const { locale, localeInSnakeCase, localeInCamelCase } = this.normalizeLangKey(lang);
this.translocoService.setActiveLang(lang);
this.translocoLocaleService.setLocale(locale);
if (ngZorroLocales[localeInSnakeCase]) {
this.nzI18nService.setLocale(ngZorroLocales[localeInSnakeCase]);
}
if (dateFnsLocales[lang]) {
this.nzI18nService.setDateLocale(dateFnsLocales[lang]);
}
if (dateFnsLocales[localeInCamelCase]) {
this.nzI18nService.setDateLocale(dateFnsLocales[localeInCamelCase]);
}
}
normalizeLangKey(lang: string) {
const locale = this.langToLocaleMapping[lang];
const localeInCamelCase = toCamelCase(locale);
const localeInSnakeCase = locale.split('-').join('_');
return { locale, localeInSnakeCase, localeInCamelCase };
}
}