fix(server): respect timezone in iso date string encoding
parent
4a8c3b60be
commit
8225d3ac5b
|
|
@ -6,7 +6,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
|
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
|
||||||
import { MaybeDehydrated } from 'src/types';
|
import { MaybeDehydrated } from 'src/types';
|
||||||
import { asDateString } from 'src/utils/date';
|
import { asDateTimeString } from 'src/utils/date';
|
||||||
import { stringToBool } from 'src/validation';
|
import { stringToBool } from 'src/validation';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
|
|
@ -195,14 +195,14 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
|
||||||
albumName: entity.albumName,
|
albumName: entity.albumName,
|
||||||
description: entity.description,
|
description: entity.description,
|
||||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||||
createdAt: asDateString(entity.createdAt),
|
createdAt: asDateTimeString(entity.createdAt),
|
||||||
updatedAt: asDateString(entity.updatedAt),
|
updatedAt: asDateTimeString(entity.updatedAt),
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
albumUsers,
|
albumUsers,
|
||||||
shared: hasSharedUser || hasSharedLink,
|
shared: hasSharedUser || hasSharedLink,
|
||||||
hasSharedLink,
|
hasSharedLink,
|
||||||
startDate: asDateString(startDate),
|
startDate: asDateTimeString(startDate),
|
||||||
endDate: asDateString(endDate),
|
endDate: asDateTimeString(endDate),
|
||||||
assetCount: entity.assets?.length || 0,
|
assetCount: entity.assets?.length || 0,
|
||||||
isActivityEnabled: entity.isActivityEnabled,
|
isActivityEnabled: entity.isActivityEnabled,
|
||||||
order: entity.order,
|
order: entity.order,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { MaybeDehydrated } from 'src/types';
|
import { MaybeDehydrated } from 'src/types';
|
||||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
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 { mimeTypes } from 'src/utils/mime-types';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
|
|
@ -199,7 +199,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
|
||||||
type: entity.type,
|
type: entity.type,
|
||||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||||
localDateTime: asDateString(entity.localDateTime),
|
localDateTime: asDateTimeString(entity.localDateTime),
|
||||||
duration: entity.duration,
|
duration: entity.duration,
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
livePhotoVideoId: entity.livePhotoVideoId,
|
||||||
hasMetadata: false,
|
hasMetadata: false,
|
||||||
|
|
@ -211,7 +211,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
createdAt: asDateString(entity.createdAt),
|
createdAt: asDateTimeString(entity.createdAt),
|
||||||
ownerId: entity.ownerId,
|
ownerId: entity.ownerId,
|
||||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||||
libraryId: entity.libraryId,
|
libraryId: entity.libraryId,
|
||||||
|
|
@ -220,10 +220,10 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
|
||||||
originalFileName: entity.originalFileName,
|
originalFileName: entity.originalFileName,
|
||||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||||
fileCreatedAt: asDateString(entity.fileCreatedAt),
|
fileCreatedAt: asDateTimeString(entity.fileCreatedAt),
|
||||||
fileModifiedAt: asDateString(entity.fileModifiedAt),
|
fileModifiedAt: asDateTimeString(entity.fileModifiedAt),
|
||||||
localDateTime: asDateString(entity.localDateTime),
|
localDateTime: asDateTimeString(entity.localDateTime),
|
||||||
updatedAt: asDateString(entity.updatedAt),
|
updatedAt: asDateTimeString(entity.updatedAt),
|
||||||
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
||||||
isArchived: entity.visibility === AssetVisibility.Archive,
|
isArchived: entity.visibility === AssetVisibility.Archive,
|
||||||
isTrashed: !!entity.deletedAt,
|
isTrashed: !!entity.deletedAt,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { Exif } from 'src/database';
|
import { Exif } from 'src/database';
|
||||||
import { MaybeDehydrated } from 'src/types';
|
import { MaybeDehydrated } from 'src/types';
|
||||||
import { asDateString } from 'src/utils/date';
|
import { asDateTimeString } from 'src/utils/date';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
export const ExifResponseSchema = z
|
export const ExifResponseSchema = z
|
||||||
|
|
@ -44,8 +44,8 @@ export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
|
||||||
exifImageHeight: entity.exifImageHeight,
|
exifImageHeight: entity.exifImageHeight,
|
||||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||||
orientation: entity.orientation,
|
orientation: entity.orientation,
|
||||||
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
|
dateTimeOriginal: asDateTimeString(entity.dateTimeOriginal),
|
||||||
modifyDate: asDateString(entity.modifyDate),
|
modifyDate: asDateTimeString(entity.modifyDate),
|
||||||
timeZone: entity.timeZone,
|
timeZone: entity.timeZone,
|
||||||
lensModel: entity.lensModel,
|
lensModel: entity.lensModel,
|
||||||
fNumber: entity.fNumber,
|
fNumber: entity.fNumber,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
import { SourceTypeSchema } from 'src/enum';
|
import { SourceTypeSchema } from 'src/enum';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
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 { transformFaceBoundingBox } from 'src/utils/transform';
|
||||||
import { hexColor, stringToBool } from 'src/validation';
|
import { hexColor, stringToBool } from 'src/validation';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
@ -175,12 +175,12 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
|
||||||
return {
|
return {
|
||||||
id: person.id,
|
id: person.id,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
birthDate: asBirthDateString(person.birthDate),
|
birthDate: asDateString(person.birthDate),
|
||||||
thumbnailPath: person.thumbnailPath,
|
thumbnailPath: person.thumbnailPath,
|
||||||
isHidden: person.isHidden,
|
isHidden: person.isHidden,
|
||||||
isFavorite: person.isFavorite,
|
isFavorite: person.isFavorite,
|
||||||
color: person.color ?? undefined,
|
color: person.color ?? undefined,
|
||||||
updatedAt: asDateString(person.updatedAt),
|
updatedAt: asDateTimeString(person.updatedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { Tag } from 'src/database';
|
import { Tag } from 'src/database';
|
||||||
import { MaybeDehydrated } from 'src/types';
|
import { MaybeDehydrated } from 'src/types';
|
||||||
import { asDateString } from 'src/utils/date';
|
import { asDateTimeString } from 'src/utils/date';
|
||||||
import { hexColor } from 'src/validation';
|
import { hexColor } from 'src/validation';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
|
|
@ -65,8 +65,8 @@ export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
|
||||||
parentId: entity.parentId ?? undefined,
|
parentId: entity.parentId ?? undefined,
|
||||||
name: entity.value.split('/').at(-1) as string,
|
name: entity.value.split('/').at(-1) as string,
|
||||||
value: entity.value,
|
value: entity.value,
|
||||||
createdAt: asDateString(entity.createdAt),
|
createdAt: asDateTimeString(entity.createdAt),
|
||||||
updatedAt: asDateString(entity.updatedAt),
|
updatedAt: asDateTimeString(entity.updatedAt),
|
||||||
color: entity.color ?? undefined,
|
color: entity.color ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { User, UserAdmin } from 'src/database';
|
||||||
import { pinCodeRegex } from 'src/dtos/auth.dto';
|
import { pinCodeRegex } from 'src/dtos/auth.dto';
|
||||||
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
||||||
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
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 { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ export const mapUser = (entity: MaybeDehydrated<User | UserAdmin>): UserResponse
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
profileImagePath: entity.profileImagePath,
|
profileImagePath: entity.profileImagePath,
|
||||||
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
|
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 { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
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';
|
import { getPreferences } from 'src/utils/preferences';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -59,11 +59,11 @@ export class AlbumService extends BaseService {
|
||||||
return albums.map((album) => ({
|
return albums.map((album) => ({
|
||||||
...mapAlbum(album),
|
...mapAlbum(album),
|
||||||
sharedLinks: undefined,
|
sharedLinks: undefined,
|
||||||
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
|
startDate: asDateTimeString(albumMetadata[album.id]?.startDate ?? undefined),
|
||||||
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
|
endDate: asDateTimeString(albumMetadata[album.id]?.endDate ?? undefined),
|
||||||
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
|
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
|
||||||
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
// 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 {
|
return {
|
||||||
...mapAlbum(album),
|
...mapAlbum(album),
|
||||||
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
|
startDate: asDateTimeString(albumMetadataForIds?.startDate ?? undefined),
|
||||||
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
|
endDate: asDateTimeString(albumMetadataForIds?.endDate ?? undefined),
|
||||||
assetCount: albumMetadataForIds?.assetCount ?? 0,
|
assetCount: albumMetadataForIds?.assetCount ?? 0,
|
||||||
lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
|
lastModifiedAssetTimestamp: asDateTimeString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
|
||||||
contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : 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 { DateTime } from 'luxon';
|
||||||
|
import { isoDateToDate, isoDatetimeToDate } from 'src/validation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a date to a ISO 8601 datetime string.
|
* 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) => {
|
export const asDateTimeString = <T extends Date | string | undefined | null>(x: T) => {
|
||||||
return x instanceof Date ? x.toISOString() : (x as Exclude<T, Date>);
|
return x instanceof Date ? isoDatetimeToDate.encode(x) : (x as Exclude<T, Date>);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a date to a date string.
|
* Convert a date to a date string (yyyy-mm-dd).
|
||||||
* @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.
|
|
||||||
*/
|
*/
|
||||||
export const asBirthDateString = (x: Date | string | null): string | null => {
|
export const asDateString = (x: Date | string | null): string | null => {
|
||||||
return x instanceof Date ? x.toISOString().split('T')[0] : x;
|
return x instanceof Date ? isoDateToDate.encode(x) : x;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractTimeZone = (dateTimeOriginal?: string | null) => {
|
export const extractTimeZone = (dateTimeOriginal?: string | null) => {
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,12 @@ export const isoDateToDate = z
|
||||||
z.date(),
|
z.date(),
|
||||||
{
|
{
|
||||||
decode: (isoString) => new Date(isoString),
|
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' });
|
.meta({ example: '2024-01-01' });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue