refactor(server)!: drop empty string to null conversion (#28808)
refactor(server): drop empty string to null conversionmain
parent
137687bc0f
commit
5c33eb3204
|
|
@ -259,17 +259,6 @@ describe('/search', () => {
|
||||||
assets: [assetHeic],
|
assets: [assetHeic],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
should: "should search city ('')",
|
|
||||||
deferred: () => ({
|
|
||||||
dto: {
|
|
||||||
city: '',
|
|
||||||
visibility: AssetVisibility.Timeline,
|
|
||||||
includeNull: true,
|
|
||||||
},
|
|
||||||
assets: [assetLast],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
should: 'should search city (null)',
|
should: 'should search city (null)',
|
||||||
deferred: () => ({
|
deferred: () => ({
|
||||||
|
|
@ -291,18 +280,6 @@ describe('/search', () => {
|
||||||
assets: [assetDensity],
|
assets: [assetDensity],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
should: "should search state ('')",
|
|
||||||
deferred: () => ({
|
|
||||||
dto: {
|
|
||||||
state: '',
|
|
||||||
visibility: AssetVisibility.Timeline,
|
|
||||||
withExif: true,
|
|
||||||
includeNull: true,
|
|
||||||
},
|
|
||||||
assets: [assetLast, assetNotocactus],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
should: 'should search state (null)',
|
should: 'should search state (null)',
|
||||||
deferred: () => ({
|
deferred: () => ({
|
||||||
|
|
@ -324,17 +301,6 @@ describe('/search', () => {
|
||||||
assets: [assetFalcon],
|
assets: [assetFalcon],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
should: "should search country ('')",
|
|
||||||
deferred: () => ({
|
|
||||||
dto: {
|
|
||||||
country: '',
|
|
||||||
visibility: AssetVisibility.Timeline,
|
|
||||||
includeNull: true,
|
|
||||||
},
|
|
||||||
assets: [assetLast],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
should: 'should search country (null)',
|
should: 'should search country (null)',
|
||||||
deferred: () => ({
|
deferred: () => ({
|
||||||
|
|
|
||||||
|
|
@ -53,16 +53,6 @@ describe(PersonController.name, () => {
|
||||||
await request(ctx.getHttpServer()).post('/people');
|
await request(ctx.getHttpServer()).post('/people');
|
||||||
expect(ctx.authenticate).toHaveBeenCalled();
|
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', () => {
|
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 () => {
|
it('should not accept an invalid birth date (false)', async () => {
|
||||||
const { status, body } = await request(ctx.getHttpServer())
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
.put(`/people/${factory.uuid()}`)
|
.put(`/people/${factory.uuid()}`)
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,5 @@ describe(TagController.name, () => {
|
||||||
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
||||||
expect(ctx.authenticate).toHaveBeenCalled();
|
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 { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||||
import { asBirthDateString, asDateString } from 'src/utils/date';
|
import { asBirthDateString, asDateString } from 'src/utils/date';
|
||||||
import { transformFaceBoundingBox } from 'src/utils/transform';
|
import { transformFaceBoundingBox } from 'src/utils/transform';
|
||||||
import { emptyStringToNull, hexColor, stringToBool } from 'src/validation';
|
import { hexColor, stringToBool } from 'src/validation';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const PersonCreateSchema = z
|
const PersonCreateSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional().describe('Person name'),
|
name: z.string().optional().describe('Person name'),
|
||||||
// Note: the mobile app cannot currently set the birth date to null.
|
birthDate: z
|
||||||
birthDate: emptyStringToNull(z.string().meta({ format: 'date' }).nullable())
|
.string()
|
||||||
|
.meta({ format: 'date' })
|
||||||
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
.refine((val) => (val ? new Date(val) <= new Date() : true), { error: 'Birth date cannot be in the future' })
|
.refine((val) => (val ? new Date(val) <= new Date() : true), { error: 'Birth date cannot be in the future' })
|
||||||
.describe('Person date of birth'),
|
.describe('Person date of birth'),
|
||||||
isHidden: z.boolean().optional().describe('Person visibility (hidden)'),
|
isHidden: z.boolean().optional().describe('Person visibility (hidden)'),
|
||||||
isFavorite: z.boolean().optional().describe('Mark as favorite'),
|
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' });
|
.meta({ id: 'PersonCreateDto' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
|
||||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
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';
|
import z from 'zod';
|
||||||
|
|
||||||
const BaseSearchSchema = z.object({
|
const BaseSearchSchema = z.object({
|
||||||
|
|
@ -23,12 +23,12 @@ const BaseSearchSchema = z.object({
|
||||||
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
|
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
|
||||||
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
|
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
|
||||||
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
|
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
|
||||||
city: emptyStringToNull(z.string().nullable()).optional().describe('Filter by city name'),
|
city: z.string().nullable().optional().describe('Filter by city name'),
|
||||||
state: emptyStringToNull(z.string().nullable()).optional().describe('Filter by state/province name'),
|
state: z.string().nullable().optional().describe('Filter by state/province name'),
|
||||||
country: emptyStringToNull(z.string().nullable()).optional().describe('Filter by country name'),
|
country: z.string().nullable().optional().describe('Filter by country name'),
|
||||||
make: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera make'),
|
make: z.string().nullable().optional().describe('Filter by camera make'),
|
||||||
model: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera model'),
|
model: z.string().nullable().optional().describe('Filter by camera model'),
|
||||||
lensModel: emptyStringToNull(z.string().nullable()).optional().describe('Filter by lens model'),
|
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
|
||||||
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
|
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
|
||||||
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
|
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
|
||||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag 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 { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
|
||||||
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
|
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { SharedLinkTypeSchema } from 'src/enum';
|
import { SharedLinkTypeSchema } from 'src/enum';
|
||||||
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
|
import { isoDatetimeToDate } from 'src/validation';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const SharedLinkSearchSchema = z
|
const SharedLinkSearchSchema = z
|
||||||
|
|
@ -23,9 +23,9 @@ const SharedLinkCreateSchema = z
|
||||||
type: SharedLinkTypeSchema,
|
type: SharedLinkTypeSchema,
|
||||||
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs (for individual assets)'),
|
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs (for individual assets)'),
|
||||||
albumId: z.uuidv4().optional().describe('Album ID (for album sharing)'),
|
albumId: z.uuidv4().optional().describe('Album ID (for album sharing)'),
|
||||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
description: z.string().nullable().optional().describe('Link description'),
|
||||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
password: z.string().nullable().optional().describe('Link password'),
|
||||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||||
expiresAt: isoDatetimeToDate.nullable().describe('Expiration date').default(null).optional(),
|
expiresAt: isoDatetimeToDate.nullable().describe('Expiration date').default(null).optional(),
|
||||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||||
allowDownload: z.boolean().default(true).optional().describe('Allow downloads'),
|
allowDownload: z.boolean().default(true).optional().describe('Allow downloads'),
|
||||||
|
|
@ -35,9 +35,9 @@ const SharedLinkCreateSchema = z
|
||||||
|
|
||||||
const SharedLinkEditSchema = z
|
const SharedLinkEditSchema = z
|
||||||
.object({
|
.object({
|
||||||
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
|
description: z.string().nullable().optional().describe('Link description'),
|
||||||
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
|
password: z.string().nullable().optional().describe('Link password'),
|
||||||
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
|
slug: z.string().nullable().optional().describe('Custom URL slug'),
|
||||||
expiresAt: isoDatetimeToDate.nullish().describe('Expiration date'),
|
expiresAt: isoDatetimeToDate.nullish().describe('Expiration date'),
|
||||||
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
allowUpload: z.boolean().optional().describe('Allow uploads'),
|
||||||
allowDownload: z.boolean().optional().describe('Allow downloads'),
|
allowDownload: z.boolean().optional().describe('Allow downloads'),
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@ 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 { asDateString } from 'src/utils/date';
|
||||||
import { emptyStringToNull, hexColor } from 'src/validation';
|
import { hexColor } from 'src/validation';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const TagCreateSchema = z
|
const TagCreateSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().describe('Tag name'),
|
name: z.string().describe('Tag name'),
|
||||||
parentId: z.uuidv4().nullish().describe('Parent tag ID'),
|
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' });
|
.meta({ id: 'TagCreateDto' });
|
||||||
|
|
||||||
const TagUpdateSchema = z
|
const TagUpdateSchema = z
|
||||||
.object({
|
.object({
|
||||||
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
|
color: hexColor.nullable().optional().describe('Tag color (hex)'),
|
||||||
})
|
})
|
||||||
.meta({ id: 'TagUpdateDto' });
|
.meta({ id: 'TagUpdateDto' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ 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 { 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';
|
import z from 'zod';
|
||||||
|
|
||||||
export const UserUpdateMeSchema = z
|
export const UserUpdateMeSchema = z
|
||||||
|
|
@ -80,10 +80,7 @@ export const UserAdminCreateSchema = z
|
||||||
password: z.string().describe('User password'),
|
password: z.string().describe('User password'),
|
||||||
name: z.string().describe('User name'),
|
name: z.string().describe('User name'),
|
||||||
avatarColor: UserAvatarColorSchema.nullish(),
|
avatarColor: UserAvatarColorSchema.nullish(),
|
||||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||||
.optional()
|
|
||||||
.describe('PIN code')
|
|
||||||
.meta({ example: '123456' }),
|
|
||||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||||
quotaSizeInBytes: z.int().min(0).nullish().describe('Storage quota in bytes'),
|
quotaSizeInBytes: z.int().min(0).nullish().describe('Storage quota in bytes'),
|
||||||
shouldChangePassword: z.boolean().optional().describe('Require password change on next login'),
|
shouldChangePassword: z.boolean().optional().describe('Require password change on next login'),
|
||||||
|
|
@ -98,10 +95,7 @@ const UserAdminUpdateSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: toEmail.optional().describe('User email'),
|
email: toEmail.optional().describe('User email'),
|
||||||
password: z.string().optional().describe('User password'),
|
password: z.string().optional().describe('User password'),
|
||||||
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
|
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
|
||||||
.optional()
|
|
||||||
.describe('PIN code')
|
|
||||||
.meta({ example: '123456' }),
|
|
||||||
name: z.string().optional().describe('User name'),
|
name: z.string().optional().describe('User name'),
|
||||||
avatarColor: UserAvatarColorSchema.nullish(),
|
avatarColor: UserAvatarColorSchema.nullish(),
|
||||||
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
|
||||||
|
|
|
||||||
|
|
@ -251,16 +251,4 @@ export const hexColor = z
|
||||||
.regex(hexColorRegex)
|
.regex(hexColorRegex)
|
||||||
.transform((val) => (val.startsWith('#') ? val : `#${val}`));
|
.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('.', '')));
|
export const sanitizeFilename = z.string().transform((val) => sanitize(val.replaceAll('.', '')));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue