From 8225d3ac5b6bd57383a0d84a6e42952bac220f59 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 3 Jun 2026 21:19:02 +0200 Subject: [PATCH] fix(server): respect timezone in iso date string encoding --- server/src/dtos/album.dto.ts | 10 ++++---- server/src/dtos/asset-response.dto.ts | 14 ++++++------ server/src/dtos/exif.dto.ts | 6 ++--- server/src/dtos/person.dto.ts | 6 ++--- server/src/dtos/tag.dto.ts | 6 ++--- server/src/dtos/user.dto.ts | 4 ++-- server/src/services/album.service.ts | 14 ++++++------ server/src/utils/date.spec.ts | 33 +++++++++++++++++++++++++++ server/src/utils/date.ts | 17 +++++--------- server/src/validation.ts | 7 +++++- 10 files changed, 75 insertions(+), 42 deletions(-) create mode 100644 server/src/utils/date.spec.ts diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 100550659d..ee6f2c07eb 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -6,7 +6,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto'; import { UserResponseSchema, mapUser } from 'src/dtos/user.dto'; import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum'; import { MaybeDehydrated } from 'src/types'; -import { asDateString } from 'src/utils/date'; +import { asDateTimeString } from 'src/utils/date'; import { stringToBool } from 'src/validation'; import z from 'zod'; @@ -195,14 +195,14 @@ export const mapAlbum = (entity: MaybeDehydrated): AlbumResponseDto albumName: entity.albumName, description: entity.description, albumThumbnailAssetId: entity.albumThumbnailAssetId, - createdAt: asDateString(entity.createdAt), - updatedAt: asDateString(entity.updatedAt), + createdAt: asDateTimeString(entity.createdAt), + updatedAt: asDateTimeString(entity.updatedAt), id: entity.id, albumUsers, shared: hasSharedUser || hasSharedLink, hasSharedLink, - startDate: asDateString(startDate), - endDate: asDateString(endDate), + startDate: asDateTimeString(startDate), + endDate: asDateTimeString(endDate), assetCount: entity.assets?.length || 0, isActivityEnabled: entity.isActivityEnabled, order: entity.order, diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 6d72fd971a..b5b0b04a2d 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -18,7 +18,7 @@ import { } from 'src/enum'; import { MaybeDehydrated } from 'src/types'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; -import { asDateString } from 'src/utils/date'; +import { asDateTimeString } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; import z from 'zod'; @@ -199,7 +199,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt type: entity.type, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - localDateTime: asDateString(entity.localDateTime), + localDateTime: asDateTimeString(entity.localDateTime), duration: entity.duration, livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -211,7 +211,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt return { id: entity.id, - createdAt: asDateString(entity.createdAt), + createdAt: asDateTimeString(entity.createdAt), ownerId: entity.ownerId, owner: entity.owner ? mapUser(entity.owner) : undefined, libraryId: entity.libraryId, @@ -220,10 +220,10 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - fileCreatedAt: asDateString(entity.fileCreatedAt), - fileModifiedAt: asDateString(entity.fileModifiedAt), - localDateTime: asDateString(entity.localDateTime), - updatedAt: asDateString(entity.updatedAt), + fileCreatedAt: asDateTimeString(entity.fileCreatedAt), + fileModifiedAt: asDateTimeString(entity.fileModifiedAt), + localDateTime: asDateTimeString(entity.localDateTime), + updatedAt: asDateTimeString(entity.updatedAt), isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite, isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 37274ee1f9..d07bbcfb0e 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -1,7 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import { Exif } from 'src/database'; import { MaybeDehydrated } from 'src/types'; -import { asDateString } from 'src/utils/date'; +import { asDateTimeString } from 'src/utils/date'; import z from 'zod'; export const ExifResponseSchema = z @@ -44,8 +44,8 @@ export function mapExif(entity: MaybeDehydrated): ExifResponseDto { exifImageHeight: entity.exifImageHeight, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, - dateTimeOriginal: asDateString(entity.dateTimeOriginal), - modifyDate: asDateString(entity.modifyDate), + dateTimeOriginal: asDateTimeString(entity.dateTimeOriginal), + modifyDate: asDateTimeString(entity.modifyDate), timeZone: entity.timeZone, lensModel: entity.lensModel, fNumber: entity.fNumber, diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index dcbbc677b9..38e856b8c1 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -7,7 +7,7 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SourceTypeSchema } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { ImageDimensions, MaybeDehydrated } from 'src/types'; -import { asBirthDateString, asDateString } from 'src/utils/date'; +import { asDateString, asDateTimeString } from 'src/utils/date'; import { transformFaceBoundingBox } from 'src/utils/transform'; import { hexColor, stringToBool } from 'src/validation'; import z from 'zod'; @@ -175,12 +175,12 @@ export function mapPerson(person: MaybeDehydrated): PersonResponseDto { return { id: person.id, name: person.name, - birthDate: asBirthDateString(person.birthDate), + birthDate: asDateString(person.birthDate), thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, isFavorite: person.isFavorite, color: person.color ?? undefined, - updatedAt: asDateString(person.updatedAt), + updatedAt: asDateTimeString(person.updatedAt), }; } diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 2fd860db13..dd679c97cb 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,7 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import { Tag } from 'src/database'; import { MaybeDehydrated } from 'src/types'; -import { asDateString } from 'src/utils/date'; +import { asDateTimeString } from 'src/utils/date'; import { hexColor } from 'src/validation'; import z from 'zod'; @@ -65,8 +65,8 @@ export function mapTag(entity: MaybeDehydrated): TagResponseDto { parentId: entity.parentId ?? undefined, name: entity.value.split('/').at(-1) as string, value: entity.value, - createdAt: asDateString(entity.createdAt), - updatedAt: asDateString(entity.updatedAt), + createdAt: asDateTimeString(entity.createdAt), + updatedAt: asDateTimeString(entity.updatedAt), color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 8d71db6618..528163e57c 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -3,7 +3,7 @@ import { User, UserAdmin } from 'src/database'; import { pinCodeRegex } from 'src/dtos/auth.dto'; import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum'; import { MaybeDehydrated, UserMetadataItem } from 'src/types'; -import { asDateString } from 'src/utils/date'; +import { asDateTimeString } from 'src/utils/date'; import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation'; import z from 'zod'; @@ -61,7 +61,7 @@ export const mapUser = (entity: MaybeDehydrated): UserResponse name: entity.name, profileImagePath: entity.profileImagePath, avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email), - profileChangedAt: asDateString(entity.profileChangedAt), + profileChangedAt: asDateTimeString(entity.profileChangedAt), }; }; diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 31c4ff2e38..564f4bfb3f 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -18,7 +18,7 @@ import { AlbumUserRole, Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; -import { asDateString } from 'src/utils/date'; +import { asDateTimeString } from 'src/utils/date'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -59,11 +59,11 @@ export class AlbumService extends BaseService { return albums.map((album) => ({ ...mapAlbum(album), sharedLinks: undefined, - startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined), - endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined), + startDate: asDateTimeString(albumMetadata[album.id]?.startDate ?? undefined), + endDate: asDateTimeString(albumMetadata[album.id]?.endDate ?? undefined), assetCount: albumMetadata[album.id]?.assetCount ?? 0, // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need - lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined), + lastModifiedAssetTimestamp: asDateTimeString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined), })); } @@ -79,10 +79,10 @@ export class AlbumService extends BaseService { return { ...mapAlbum(album), - startDate: asDateString(albumMetadataForIds?.startDate ?? undefined), - endDate: asDateString(albumMetadataForIds?.endDate ?? undefined), + startDate: asDateTimeString(albumMetadataForIds?.startDate ?? undefined), + endDate: asDateTimeString(albumMetadataForIds?.endDate ?? undefined), assetCount: albumMetadataForIds?.assetCount ?? 0, - lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined), + lastModifiedAssetTimestamp: asDateTimeString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined), contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined, }; } diff --git a/server/src/utils/date.spec.ts b/server/src/utils/date.spec.ts new file mode 100644 index 0000000000..729b515467 --- /dev/null +++ b/server/src/utils/date.spec.ts @@ -0,0 +1,33 @@ +import { asDateString, asDateTimeString } from 'src/utils/date'; +import { describe, expect, it } from 'vitest'; + +describe('asDateString', () => { + it('should return null for null input', () => { + expect(asDateString(null)).toBeNull(); + }); + + it('should pass through a pre-serialized string unchanged', () => { + expect(asDateString('2000-01-15')).toBe('2000-01-15'); + }); + + it('should return the local calendar date, not the UTC date', () => { + const date = new Date(2000, 0, 15); // 15 Jan 2000, local midnight + expect(asDateString(date)).toBe('2000-01-15'); + }); +}); + +describe('asDateTimeString', () => { + it('should return null for null input', () => { + expect(asDateTimeString(null)).toBeNull(); + }); + + it('should pass through a pre-serialized string unchanged', () => { + const iso = '2000-01-15T12:00:00.000Z'; + expect(asDateTimeString(iso)).toBe(iso); + }); + + it('should return an ISO 8601 datetime string for a Date', () => { + const date = new Date('2000-01-15T12:00:00.000Z'); + expect(asDateTimeString(date)).toBe('2000-01-15T12:00:00.000Z'); + }); +}); diff --git a/server/src/utils/date.ts b/server/src/utils/date.ts index d4de1eba86..394bab8a02 100644 --- a/server/src/utils/date.ts +++ b/server/src/utils/date.ts @@ -1,23 +1,18 @@ import { DateTime } from 'luxon'; +import { isoDateToDate, isoDatetimeToDate } from 'src/validation'; /** * Convert a date to a ISO 8601 datetime string. - * @param x - The date to convert. - * @returns The ISO 8601 datetime string. - * @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDatetimeToDate` in validation.ts will handle the conversion instead. */ -export const asDateString = (x: T) => { - return x instanceof Date ? x.toISOString() : (x as Exclude); +export const asDateTimeString = (x: T) => { + return x instanceof Date ? isoDatetimeToDate.encode(x) : (x as Exclude); }; /** - * Convert a date to a date string. - * @param x - The date to convert. - * @returns The date string. - * @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDateToDate` in validation.ts will handle the conversion instead. + * Convert a date to a date string (yyyy-mm-dd). */ -export const asBirthDateString = (x: Date | string | null): string | null => { - return x instanceof Date ? x.toISOString().split('T')[0] : x; +export const asDateString = (x: Date | string | null): string | null => { + return x instanceof Date ? isoDateToDate.encode(x) : x; }; export const extractTimeZone = (dateTimeOriginal?: string | null) => { diff --git a/server/src/validation.ts b/server/src/validation.ts index 6a57bc5582..95bfe003a4 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -166,7 +166,12 @@ export const isoDateToDate = z z.date(), { decode: (isoString) => new Date(isoString), - encode: (date) => date.toISOString().slice(0, 10), + encode: (date) => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + }, }, ) .meta({ example: '2024-01-01' });