mirror of
https://github.com/immich-app/immich.git
synced 2026-06-05 14:25:16 -04:00
refactor(server)!: drop empty string to null conversion (#28808)
refactor(server): drop empty string to null conversion
This commit is contained in:
@@ -53,16 +53,6 @@ describe(PersonController.name, () => {
|
||||
await request(ctx.getHttpServer()).post('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should map an empty color to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ color: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { color: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /people', () => {
|
||||
@@ -153,12 +143,6 @@ describe(PersonController.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should not accept an invalid birth date (false)', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
|
||||
@@ -63,11 +63,5 @@ describe(TagController.name, () => {
|
||||
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow setting a null color via an empty string', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,20 +9,22 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||
import { asBirthDateString, asDateString } from 'src/utils/date';
|
||||
import { transformFaceBoundingBox } from 'src/utils/transform';
|
||||
import { emptyStringToNull, hexColor, stringToBool } from 'src/validation';
|
||||
import { hexColor, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const PersonCreateSchema = z
|
||||
.object({
|
||||
name: z.string().optional().describe('Person name'),
|
||||
// Note: the mobile app cannot currently set the birth date to null.
|
||||
birthDate: emptyStringToNull(z.string().meta({ format: 'date' }).nullable())
|
||||
birthDate: z
|
||||
.string()
|
||||
.meta({ format: 'date' })
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine((val) => (val ? new Date(val) <= new Date() : true), { error: 'Birth date cannot be in the future' })
|
||||
.describe('Person date of birth'),
|
||||
isHidden: z.boolean().optional().describe('Person visibility (hidden)'),
|
||||
isFavorite: z.boolean().optional().describe('Mark as favorite'),
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Person color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Person color (hex)'),
|
||||
})
|
||||
.meta({ id: 'PersonCreateDto' });
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import { isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const BaseSearchSchema = z.object({
|
||||
@@ -23,12 +23,12 @@ const BaseSearchSchema = z.object({
|
||||
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
|
||||
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
|
||||
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
|
||||
city: emptyStringToNull(z.string().nullable()).optional().describe('Filter by city name'),
|
||||
state: emptyStringToNull(z.string().nullable()).optional().describe('Filter by state/province name'),
|
||||
country: emptyStringToNull(z.string().nullable()).optional().describe('Filter by country name'),
|
||||
make: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera make'),
|
||||
model: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera model'),
|
||||
lensModel: emptyStringToNull(z.string().nullable()).optional().describe('Filter by lens model'),
|
||||
city: z.string().nullable().optional().describe('Filter by city name'),
|
||||
state: z.string().nullable().optional().describe('Filter by state/province name'),
|
||||
country: z.string().nullable().optional().describe('Filter by country name'),
|
||||
make: z.string().nullable().optional().describe('Filter by camera make'),
|
||||
model: z.string().nullable().optional().describe('Filter by camera model'),
|
||||
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
|
||||
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
|
||||
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkTypeSchema } from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const SharedLinkSearchSchema = z
|
||||
@@ -23,9 +23,9 @@ const SharedLinkCreateSchema = z
|
||||
type: SharedLinkTypeSchema,
|
||||
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs (for individual assets)'),
|
||||
albumId: z.uuidv4().optional().describe('Album ID (for album sharing)'),
|
||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
||||
description: z.string().nullable().optional().describe('Link description'),
|
||||
password: z.string().nullable().optional().describe('Link password'),
|
||||
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||
expiresAt: isoDatetimeToDate.nullable().describe('Expiration date').default(null).optional(),
|
||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||
allowDownload: z.boolean().default(true).optional().describe('Allow downloads'),
|
||||
@@ -35,9 +35,9 @@ const SharedLinkCreateSchema = z
|
||||
|
||||
const SharedLinkEditSchema = z
|
||||
.object({
|
||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
||||
description: z.string().nullable().optional().describe('Link description'),
|
||||
password: z.string().nullable().optional().describe('Link password'),
|
||||
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||
expiresAt: isoDatetimeToDate.nullish().describe('Expiration date'),
|
||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||
allowDownload: z.boolean().optional().describe('Allow downloads'),
|
||||
|
||||
@@ -2,20 +2,20 @@ import { createZodDto } from 'nestjs-zod';
|
||||
import { Tag } from 'src/database';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { emptyStringToNull, hexColor } from 'src/validation';
|
||||
import { hexColor } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const TagCreateSchema = z
|
||||
.object({
|
||||
name: z.string().describe('Tag name'),
|
||||
parentId: z.uuidv4().nullish().describe('Parent tag ID'),
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Tag color (hex)'),
|
||||
})
|
||||
.meta({ id: 'TagCreateDto' });
|
||||
|
||||
const TagUpdateSchema = z
|
||||
.object({
|
||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
|
||||
color: hexColor.nullable().optional().describe('Tag color (hex)'),
|
||||
})
|
||||
.meta({ id: 'TagUpdateDto' });
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||
import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
export const UserUpdateMeSchema = z
|
||||
@@ -80,10 +80,7 @@ export const UserAdminCreateSchema = z
|
||||
password: z.string().describe('User password'),
|
||||
name: z.string().describe('User name'),
|
||||
avatarColor: UserAvatarColorSchema.nullish(),
|
||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
||||
.optional()
|
||||
.describe('PIN code')
|
||||
.meta({ example: '123456' }),
|
||||
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||
quotaSizeInBytes: z.int().min(0).nullish().describe('Storage quota in bytes'),
|
||||
shouldChangePassword: z.boolean().optional().describe('Require password change on next login'),
|
||||
@@ -98,10 +95,7 @@ const UserAdminUpdateSchema = z
|
||||
.object({
|
||||
email: toEmail.optional().describe('User email'),
|
||||
password: z.string().optional().describe('User password'),
|
||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
||||
.optional()
|
||||
.describe('PIN code')
|
||||
.meta({ example: '123456' }),
|
||||
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||
name: z.string().optional().describe('User name'),
|
||||
avatarColor: UserAvatarColorSchema.nullish(),
|
||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||
|
||||
@@ -251,16 +251,4 @@ export const hexColor = z
|
||||
.regex(hexColorRegex)
|
||||
.transform((val) => (val.startsWith('#') ? val : `#${val}`));
|
||||
|
||||
/**
|
||||
* Transform empty strings to null. Inner schema passed to this function must accept null.
|
||||
* @docs https://zod.dev/api?id=preprocess
|
||||
* @example emptyStringToNull(z.string().nullable()).optional() // [encouraged] final schema is optional
|
||||
* @example emptyStringToNull(z.string().nullable()) // [encouraged] same as the one above, but final schema is not optional
|
||||
* @example emptyStringToNull(z.string().nullish()) // [discouraged] same as the one above, might be confusing
|
||||
* @example emptyStringToNull(z.string().optional()) // fails: string schema rejects null
|
||||
* @example emptyStringToNull(z.string().nullable()).nullish() // [discouraged] passes, null is duplicated. use the first example instead
|
||||
*/
|
||||
export const emptyStringToNull = <T extends z.ZodTypeAny>(schema: T) =>
|
||||
z.preprocess((val) => (val === '' ? null : val), schema);
|
||||
|
||||
export const sanitizeFilename = z.string().transform((val) => sanitize(val.replaceAll('.', '')));
|
||||
|
||||
Reference in New Issue
Block a user