mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:55:19 -04:00
fix(server): respect timezone in iso date string encoding (#28810)
This commit is contained in:
@@ -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<MapAlbumDto>): 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,
|
||||
|
||||
@@ -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<MapAsset>, 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<MapAsset>, 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<MapAsset>, 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,
|
||||
|
||||
@@ -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<Exif>): 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,
|
||||
|
||||
@@ -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<Person>): 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Tag>): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<User | UserAdmin>): UserResponse
|
||||
name: entity.name,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
|
||||
profileChangedAt: asDateString(entity.profileChangedAt),
|
||||
profileChangedAt: asDateTimeString(entity.profileChangedAt),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 = <T extends Date | string | undefined | null>(x: T) => {
|
||||
return x instanceof Date ? x.toISOString() : (x as Exclude<T, Date>);
|
||||
export const asDateTimeString = <T extends Date | string | undefined | null>(x: T) => {
|
||||
return x instanceof Date ? isoDatetimeToDate.encode(x) : (x as Exclude<T, Date>);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user