server migration

This commit is contained in:
timonrieger 2026-02-27 15:38:36 +01:00
parent 4af9edc20b
commit 3069214d93
No known key found for this signature in database
99 changed files with 4706 additions and 6486 deletions

View File

@ -95,8 +95,8 @@ describe('/asset', () => {
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken, {
isFavorite: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
fileCreatedAt: yesterday.toUTC().toISO(),
fileModifiedAt: yesterday.toUTC().toISO(),
assetData: { filename: 'example.mp4' },
}),
utils.createAsset(user1.accessToken),
@ -435,7 +435,8 @@ describe('/asset', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/assets/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});

View File

@ -110,7 +110,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
});
it('should not create an external library with duplicate exclusion patterns', async () => {
@ -125,7 +125,7 @@ describe('/libraries', () => {
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
});
});
@ -157,7 +157,7 @@ describe('/libraries', () => {
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters']));
});
it('should change the import paths', async () => {
@ -181,7 +181,7 @@ describe('/libraries', () => {
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in importPaths should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty']));
});
it('should reject duplicate import paths', async () => {
@ -191,7 +191,7 @@ describe('/libraries', () => {
.send({ importPaths: ['/path', '/path'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All importPaths's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items']));
});
it('should change the exclusion pattern', async () => {
@ -215,7 +215,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items']));
});
it('should reject an empty exclusion pattern', async () => {
@ -225,7 +225,7 @@ describe('/libraries', () => {
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in exclusionPatterns should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty']));
});
});

View File

@ -109,7 +109,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lon=123')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
});
it('should throw an error if a lat is not a number', async () => {
@ -117,7 +117,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN']));
});
it('should throw an error if a lat is out of range', async () => {
@ -125,7 +125,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lat must be a number between -90 and 90']));
expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90']));
});
it('should throw an error if a lon is not provided', async () => {
@ -133,7 +133,7 @@ describe('/map', () => {
.get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['lon must be a number between -180 and 180']));
expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN']));
});
const reverseGeocodeTestCases = [

View File

@ -101,7 +101,7 @@ describe(`/oauth`, () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined']));
});
it('should return a redirect uri', async () => {
@ -123,13 +123,13 @@ describe(`/oauth`, () => {
it(`should throw an error if a url is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['url must be a string', 'url should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined']));
});
it(`should throw an error if the url is empty`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['url should not be empty']));
expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters']));
});
it(`should throw an error if the state is not provided`, async () => {

View File

@ -309,7 +309,7 @@ describe('/tags', () => {
.get(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should get tag details', async () => {
@ -427,7 +427,7 @@ describe('/tags', () => {
.delete(`/tags/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should delete a tag', async () => {

View File

@ -287,7 +287,8 @@ describe('/admin/users', () => {
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({});
expect(status).toBe(200);
expect(body).toMatchObject({

View File

@ -178,7 +178,9 @@ describe('/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['download.archiveSize must be an integer number']));
expect(body).toEqual(
errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']),
);
});
it('should update download archive size', async () => {
@ -204,7 +206,9 @@ describe('/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value']));
expect(body).toEqual(
errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']),
);
});
it('should update download include embedded videos', async () => {

View File

@ -77,7 +77,7 @@ export function generateAsset(
latitude: hasGPS ? faker.location.latitude() : null,
longitude: hasGPS ? faker.location.longitude() : null,
visibility: AssetVisibility.Timeline,
stack: null,
stack: undefined,
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
checksum: faker.string.alphanumeric({ length: 5 }),
};

View File

@ -52,7 +52,7 @@ export type MockTimelineAsset = {
latitude: number | null;
longitude: number | null;
visibility: AssetVisibility;
stack: null;
stack: undefined;
checksum: string;
fileSizeInByte: number;
};

View File

@ -69,7 +69,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
tags: [],
people: [],
unassignedFaces: [],
stack: null,
stack: undefined,
isOffline: false,
hasMetadata: true,
duplicateId: null,

279
pnpm-lock.yaml generated
View File

@ -109,7 +109,7 @@ importers:
version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
version: 8.0.2(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^4.0.0
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@ -284,7 +284,7 @@ importers:
version: 5.2.1(encoding@0.1.13)
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 6.1.1(typescript@5.9.3)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: ^4.0.0
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@ -514,6 +514,9 @@ importers:
nestjs-otel:
specifier: ^7.0.0
version: 7.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
nestjs-zod:
specifier: ^5.2.0
version: 5.2.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
nodemailer:
specifier: ^7.0.0
version: 7.0.13
@ -583,6 +586,9 @@ importers:
validator:
specifier: ^13.12.0
version: 13.15.26
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@eslint/js':
specifier: ^10.0.0
@ -727,7 +733,7 @@ importers:
version: 1.5.9(@swc/core@1.15.18(@swc/helpers@0.5.17))(rollup@4.55.1)
vite-tsconfig-paths:
specifier: ^6.0.0
version: 6.1.1(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 6.1.1(typescript@5.9.3)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@ -745,7 +751,7 @@ importers:
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.65.3
version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.3.0
version: 0.3.0
@ -872,25 +878,25 @@ importers:
version: 3.1.2
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
'@sveltejs/enhanced-img':
specifier: ^0.10.4
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit':
specifier: ^2.27.1
version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte':
specifier: 7.0.0
version: 7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 4.2.2(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/jest-dom':
specifier: ^6.4.2
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 5.3.1(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@ -953,7 +959,7 @@ importers:
version: 3.5.1(prettier@3.8.1)(svelte@5.53.13)
rollup-plugin-visualizer:
specifier: ^6.0.0
version: 6.0.11(rolldown@1.0.0-rc.9)(rollup@4.55.1)
version: 6.0.11(rolldown@1.0.0-rc.11)(rollup@4.55.1)
svelte:
specifier: 5.53.13
version: 5.53.13
@ -974,7 +980,7 @@ importers:
version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
version: 8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^4.0.0
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@ -3810,12 +3816,8 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@oxc-project/runtime@0.115.0':
resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==}
engines: {node: ^20.19.0 || >=22.12.0}
'@oxc-project/types@0.115.0':
resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==}
'@oxc-project/types@0.122.0':
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@ -4131,103 +4133,103 @@ packages:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@rolldown/binding-android-arm64@1.0.0-rc.9':
resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==}
'@rolldown/binding-android-arm64@1.0.0-rc.11':
resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.0-rc.9':
resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==}
'@rolldown/binding-darwin-arm64@1.0.0-rc.11':
resolution: {integrity: sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.0-rc.9':
resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==}
'@rolldown/binding-darwin-x64@1.0.0-rc.11':
resolution: {integrity: sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.0-rc.9':
resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==}
'@rolldown/binding-freebsd-x64@1.0.0-rc.11':
resolution: {integrity: sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9':
resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==}
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11':
resolution: {integrity: sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==}
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11':
resolution: {integrity: sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.9':
resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==}
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.11':
resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==}
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11':
resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==}
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11':
resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.9':
resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==}
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.11':
resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.9':
resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==}
'@rolldown/binding-linux-x64-musl@1.0.0-rc.11':
resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.9':
resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==}
'@rolldown/binding-openharmony-arm64@1.0.0-rc.11':
resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.0-rc.9':
resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==}
'@rolldown/binding-wasm32-wasi@1.0.0-rc.11':
resolution: {integrity: sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9':
resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==}
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11':
resolution: {integrity: sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.9':
resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==}
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.11':
resolution: {integrity: sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.0-rc.9':
resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==}
'@rolldown/pluginutils@1.0.0-rc.11':
resolution: {integrity: sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==}
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
@ -9325,6 +9327,17 @@ packages:
'@nestjs/common': '>= 11 < 12'
'@nestjs/core': '>= 11 < 12'
nestjs-zod@5.2.0:
resolution: {integrity: sha512-fefCJlwQnrGotbli6Od/7fBqF567B4KibXEIUl7PEVT3Ft/3iolJ/fIsVTWGzZfpWHhoV23akhUaGLfxYLNJBw==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0
rxjs: ^7.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
'@nestjs/swagger':
optional: true
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
@ -10726,8 +10739,8 @@ packages:
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rolldown@1.0.0-rc.9:
resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==}
rolldown@1.0.0-rc.11:
resolution: {integrity: sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@ -11991,13 +12004,13 @@ packages:
yaml:
optional: true
vite@8.0.0:
resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==}
vite@8.0.2:
resolution: {integrity: sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.0.0-alpha.31
'@vitejs/devtools': ^0.1.0
esbuild: ^0.27.0
jiti: '>=1.21.0'
less: ^4.0.0
@ -12438,6 +12451,9 @@ packages:
zod@4.2.1:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zwitch@1.0.5:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
@ -15123,12 +15139,12 @@ snapshots:
node-emoji: 2.2.0
svelte: 5.53.13
'@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
'@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.13)
'@internationalized/date': 3.10.0
'@mdi/js': 7.4.47
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
luxon: 3.7.2
simple-icons: 16.9.0
svelte: 5.53.13
@ -16077,9 +16093,7 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@oxc-project/runtime@0.115.0': {}
'@oxc-project/types@0.115.0': {}
'@oxc-project/types@0.122.0': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
@ -16340,54 +16354,54 @@ snapshots:
'@codemirror/state': 6.5.3
'@codemirror/view': 6.39.8
'@rolldown/binding-android-arm64@1.0.0-rc.9':
'@rolldown/binding-android-arm64@1.0.0-rc.11':
optional: true
'@rolldown/binding-darwin-arm64@1.0.0-rc.9':
'@rolldown/binding-darwin-arm64@1.0.0-rc.11':
optional: true
'@rolldown/binding-darwin-x64@1.0.0-rc.9':
'@rolldown/binding-darwin-x64@1.0.0-rc.11':
optional: true
'@rolldown/binding-freebsd-x64@1.0.0-rc.9':
'@rolldown/binding-freebsd-x64@1.0.0-rc.11':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9':
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9':
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.9':
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.11':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9':
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9':
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.9':
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.11':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.0-rc.9':
'@rolldown/binding-linux-x64-musl@1.0.0-rc.11':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.0-rc.9':
'@rolldown/binding-openharmony-arm64@1.0.0-rc.11':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.9':
'@rolldown/binding-wasm32-wasi@1.0.0-rc.11':
dependencies:
'@napi-rs/wasm-runtime': 1.1.1
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9':
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.9':
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.11':
optional: true
'@rolldown/pluginutils@1.0.0-rc.9': {}
'@rolldown/pluginutils@1.0.0-rc.11': {}
'@rollup/pluginutils@5.3.0(rollup@4.55.1)':
dependencies:
@ -16528,29 +16542,29 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
dependencies:
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21
sharp: 0.34.5
svelte: 5.53.13
svelte-parse-markup: 0.1.5(svelte@5.53.13)
vite: 8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-imagetools: 9.0.3(rollup@4.55.1)
zimmerframe: 1.1.4
transitivePeerDependencies:
- rollup
- supports-color
'@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
@ -16562,19 +16576,19 @@ snapshots:
set-cookie-parser: 3.0.1
sirv: 3.0.2
svelte: 5.53.13
vite: 8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@opentelemetry/api': 1.9.0
typescript: 5.9.3
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.53.13
vite: 8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.2(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vite: 8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.2(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)':
dependencies:
@ -16791,12 +16805,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
'@tailwindcss/vite@4.2.2(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@tailwindcss/vite@4.2.2(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
vite: 8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@testing-library/dom@10.4.1':
dependencies:
@ -16822,13 +16836,13 @@ snapshots:
dependencies:
svelte: 5.53.13
'@testing-library/svelte@5.3.1(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@testing-library/svelte@5.3.1(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.53.13)
svelte: 5.53.13
optionalDependencies:
vite: 8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
@ -18115,15 +18129,15 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
svelte: 5.53.13
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
tabbable: 6.4.0
transitivePeerDependencies:
- '@sveltejs/kit'
@ -22370,6 +22384,15 @@ snapshots:
response-time: 2.3.4
tslib: 2.8.1
nestjs-zod@5.2.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1
rxjs: 7.8.2
zod: 4.3.6
optionalDependencies:
'@nestjs/swagger': 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
next-tick@1.1.0: {}
no-case@3.0.4:
@ -23911,35 +23934,35 @@ snapshots:
robust-predicates@3.0.2: {}
rolldown@1.0.0-rc.9:
rolldown@1.0.0-rc.11:
dependencies:
'@oxc-project/types': 0.115.0
'@rolldown/pluginutils': 1.0.0-rc.9
'@oxc-project/types': 0.122.0
'@rolldown/pluginutils': 1.0.0-rc.11
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-rc.9
'@rolldown/binding-darwin-arm64': 1.0.0-rc.9
'@rolldown/binding-darwin-x64': 1.0.0-rc.9
'@rolldown/binding-freebsd-x64': 1.0.0-rc.9
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.9
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.9
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.9
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9
'@rolldown/binding-android-arm64': 1.0.0-rc.11
'@rolldown/binding-darwin-arm64': 1.0.0-rc.11
'@rolldown/binding-darwin-x64': 1.0.0-rc.11
'@rolldown/binding-freebsd-x64': 1.0.0-rc.11
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.11
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.11
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.11
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.11
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.11
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.11
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.11
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.11
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.11
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.11
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.11
rollup-plugin-visualizer@6.0.11(rolldown@1.0.0-rc.9)(rollup@4.55.1):
rollup-plugin-visualizer@6.0.11(rolldown@1.0.0-rc.11)(rollup@4.55.1):
dependencies:
open: 8.4.2
picomatch: 4.0.3
source-map: 0.7.6
yargs: 17.7.2
optionalDependencies:
rolldown: 1.0.0-rc.9
rolldown: 1.0.0-rc.11
rollup: 4.55.1
rollup@4.55.1:
@ -24008,14 +24031,14 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.53.13
optionalDependencies:
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
rw@1.3.3: {}
@ -24742,10 +24765,10 @@ snapshots:
dependencies:
svelte-floating-ui: 1.5.8
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies:
clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
style-to-object: 1.0.14
svelte: 5.53.13
transitivePeerDependencies:
@ -25466,12 +25489,12 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
- typescript
@ -25512,13 +25535,12 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0
picomatch: 4.0.3
postcss: 8.5.8
rolldown: 1.0.0-rc.9
rolldown: 1.0.0-rc.11
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.0
@ -25530,13 +25552,12 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0
picomatch: 4.0.3
postcss: 8.5.8
rolldown: 1.0.0-rc.9
rolldown: 1.0.0-rc.11
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.4.0
@ -25548,9 +25569,9 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitefu@1.1.2(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
vitefu@1.1.2(vite@8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
optionalDependencies:
vite: 8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest-fetch-mock@0.4.5(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
@ -26105,6 +26126,8 @@ snapshots:
zod@4.2.1: {}
zod@4.3.6: {}
zwitch@1.0.5: {}
zwitch@2.0.4: {}

View File

@ -92,6 +92,7 @@
"nestjs-cls": "^5.0.0",
"nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0",
"nestjs-zod": "^5.2.0",
"nodemailer": "^7.0.0",
"openid-client": "^6.3.3",
"pg": "^8.11.3",
@ -114,7 +115,8 @@
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0"
"validator": "^13.12.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^10.0.0",

View File

@ -1,10 +1,11 @@
import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { Inject, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
@ -41,7 +42,8 @@ const common = [...repositories, ...services, GlobalExceptionFilter];
const commonMiddleware = [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_PIPE, useClass: ZodValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
];

View File

@ -27,13 +27,15 @@ describe(ActivityController.name, () => {
it('should require an albumId', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
expect(body).toEqual(
factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']),
);
});
it('should reject an invalid albumId', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
});
it('should reject an invalid assetId', async () => {
@ -41,7 +43,7 @@ describe(ActivityController.name, () => {
.get('/activities')
.query({ albumId: factory.uuid(), assetId: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
});
});
@ -52,9 +54,11 @@ describe(ActivityController.name, () => {
});
it('should require an albumId', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/activities').send({ albumId: '123' });
const { status, body } = await request(ctx.getHttpServer())
.post('/activities')
.send({ albumId: '123', type: 'like' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
});
it('should require a comment when type is comment', async () => {
@ -62,7 +66,7 @@ describe(ActivityController.name, () => {
.post('/activities')
.send({ albumId: factory.uuid(), type: 'comment', comment: null });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['comment must be a string', 'comment should not be empty']));
expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null']));
});
});
@ -75,7 +79,7 @@ describe(ActivityController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
});

View File

@ -27,13 +27,13 @@ describe(AlbumController.name, () => {
it('should reject an invalid shared param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['shared must be a boolean value']));
expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"']));
});
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['assetId must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
});
});

View File

@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => {
.put(`/api-keys/123`)
.send({ name: 'new name', permissions: [Permission.All] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
it('should allow updating just the name', async () => {
@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
});

View File

@ -82,7 +82,9 @@ describe(AssetMediaController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
expect(body).toEqual(
factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']),
);
});
it('should require `deviceAssetId`', async () => {
@ -92,7 +94,7 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
factory.responses.badRequest(['[deviceAssetId] Invalid input: expected string, received undefined']),
);
});
@ -102,7 +104,9 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty']));
expect(body).toEqual(
factory.responses.badRequest(['[deviceId] Invalid input: expected string, received undefined']),
);
});
it('should require `fileCreatedAt`', async () => {
@ -112,7 +116,9 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
factory.responses.badRequest([
'[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
]),
);
});
@ -123,7 +129,9 @@ describe(AssetMediaController.name, () => {
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']),
factory.responses.badRequest([
'[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
]),
);
});
@ -133,7 +141,9 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
expect(body).toEqual(
factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']),
);
});
it('should throw if `visibility` is not an enum', async () => {
@ -143,7 +153,7 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
);
});

View File

@ -31,7 +31,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['each value in ids must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
});
it('should require duplicateId to be a string', async () => {
@ -41,7 +41,9 @@ describe(AssetController.name, () => {
.send({ ids: [id], duplicateId: true });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['duplicateId must be a string']));
expect(body).toEqual(
factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']),
);
});
it('should accept a null duplicateId', async () => {
@ -68,7 +70,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['each value in ids must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
});
});
@ -81,7 +83,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
@ -95,7 +97,12 @@ describe(AssetController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(expect.arrayContaining(['sourceId must be a UUID', 'targetId must be a UUID'])),
factory.responses.badRequest(
expect.arrayContaining([
'[sourceId] Invalid input: expected string, received undefined',
'[targetId] Invalid input: expected string, received undefined',
]),
),
);
});
@ -118,7 +125,7 @@ describe(AssetController.name, () => {
.put('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
});
it('should require a key', async () => {
@ -128,7 +135,7 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
);
});
@ -152,7 +159,7 @@ describe(AssetController.name, () => {
.delete('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test' }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
});
it('should require a key', async () => {
@ -162,7 +169,7 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
);
});
@ -184,7 +191,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined']));
});
it('should reject invalid gps coordinates', async () => {
@ -247,9 +254,7 @@ describe(AssetController.name, () => {
it('should not allow count to be a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/assets/random?count=ABC');
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['count must be a positive number', 'count must be an integer number']),
);
expect(body).toEqual(factory.responses.badRequest(['[count] Invalid input: expected number, received NaN']));
});
});
@ -269,13 +274,13 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
});
it('should require items to be an array', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['items must be an array']));
expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined']));
});
it('should require each item to have a valid key', async () => {
@ -284,7 +289,7 @@ describe(AssetController.name, () => {
.send({ items: [{ value: { some: 'value' } }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']),
factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']),
);
});
@ -294,7 +299,9 @@ describe(AssetController.name, () => {
.send({ items: [{ key: 'mobile-app', value: null }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])),
factory.responses.badRequest(
expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']),
),
);
});
@ -332,7 +339,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
});
});
@ -382,7 +389,7 @@ describe(AssetController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
});
it('should check the action and parameters discriminator', async () => {
@ -405,7 +412,11 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('parameters.angle must be one of the following values')]),
expect.arrayContaining([
expect.stringContaining(
"[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle",
),
]),
),
);
});
@ -415,7 +426,7 @@ describe(AssetController.name, () => {
.put(`/assets/${factory.uuid()}/edits`)
.send({ edits: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['edits must contain at least 1 elements']));
expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items']));
});
});
@ -428,7 +439,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
});
});
});

View File

@ -74,10 +74,8 @@ describe(AuthController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'email should not be empty',
'email must be an email',
'password should not be empty',
'password must be a string',
'[email] Invalid input: expected email, received undefined',
'[password] Invalid input: expected string, received undefined',
]),
);
});
@ -87,7 +85,7 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: null, password: 'password' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['email should not be empty', 'email must be an email']));
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
});
it(`should not allow null password`, async () => {
@ -95,7 +93,7 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: 'admin@immich.cloud', password: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['password should not be empty', 'password must be a string']));
expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null']));
});
it('should reject an invalid email', async () => {
@ -106,7 +104,7 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: [], password: 'password' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['email must be an email']));
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
});
it('should transform the email to all lowercase', async () => {
@ -197,19 +195,19 @@ describe(AuthController.name, () => {
it('should reject 5 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
});
it('should reject 7 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
});
it('should reject non-numbers', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
});
});

View File

@ -31,7 +31,7 @@ describe(MaintenanceController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['restoreBackupFilename must be a string', 'restoreBackupFilename should not be empty']),
errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']),
);
expect(ctx.authenticate).toHaveBeenCalled();
});

View File

@ -47,9 +47,7 @@ describe(MemoryController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
);
expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined']));
});
it('should accept showAt and hideAt', async () => {
@ -83,7 +81,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
});
@ -96,7 +94,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
});
});
@ -116,7 +114,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should require a valid asset id', async () => {
@ -124,7 +122,7 @@ describe(MemoryController.name, () => {
.put(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
});
});
@ -137,7 +135,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should require a valid asset id', async () => {
@ -145,7 +143,7 @@ describe(MemoryController.name, () => {
.delete(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
});
});
});

View File

@ -31,7 +31,7 @@ describe(NotificationController.name, () => {
.query({ level: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')]));
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')]));
});
});
@ -45,7 +45,7 @@ describe(NotificationController.name, () => {
it('should require a list', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['ids must be an array'])));
expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean']));
});
it('should require uuids', async () => {
@ -53,7 +53,7 @@ describe(NotificationController.name, () => {
.put(`/notifications`)
.send({ ids: [true] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean']));
});
it('should accept valid uuids', async () => {
@ -75,7 +75,7 @@ describe(NotificationController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
});

View File

@ -33,10 +33,7 @@ describe(PartnerController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'direction should not be empty',
expect.stringContaining('direction must be one of the following values:'),
]),
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
);
});
@ -47,7 +44,7 @@ describe(PartnerController.name, () => {
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('direction must be one of the following values:')]),
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
);
});
});
@ -64,7 +61,7 @@ describe(PartnerController.name, () => {
.send({ sharedWithId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[sharedWithId] Invalid UUID']));
});
});
@ -80,7 +77,7 @@ describe(PartnerController.name, () => {
.send({ inTimeline: true })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
});
@ -95,7 +92,7 @@ describe(PartnerController.name, () => {
.delete(`/partners/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
});
});

View File

@ -35,7 +35,7 @@ describe(PersonController.name, () => {
.query({ closestPersonId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[closestPersonId] Invalid UUID']));
});
it(`should require closestAssetId to be a uuid`, async () => {
@ -44,7 +44,7 @@ describe(PersonController.name, () => {
.query({ closestAssetId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[closestAssetId] Invalid UUID']));
});
});
@ -76,7 +76,7 @@ describe(PersonController.name, () => {
.delete('/people')
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
});
it('should respond with 204', async () => {
@ -104,7 +104,7 @@ describe(PersonController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
});
it(`should not allow a null name`, async () => {
@ -113,7 +113,7 @@ describe(PersonController.name, () => {
.send({ name: null })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name must be a string']));
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received null']));
});
it(`should require featureFaceAssetId to be a uuid`, async () => {
@ -122,7 +122,7 @@ describe(PersonController.name, () => {
.send({ featureFaceAssetId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['featureFaceAssetId must be a UUID']));
expect(body).toEqual(errorDto.badRequest(['[featureFaceAssetId] Invalid UUID']));
});
it(`should require isFavorite to be a boolean`, async () => {
@ -131,7 +131,7 @@ describe(PersonController.name, () => {
.send({ isFavorite: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isFavorite must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
});
it(`should require isHidden to be a boolean`, async () => {
@ -140,7 +140,7 @@ describe(PersonController.name, () => {
.send({ isHidden: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isHidden must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string']));
});
it('should map an empty birthDate to null', async () => {
@ -154,12 +154,7 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: false });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'birthDate must be a string in the format yyyy-MM-dd',
'Birth date cannot be in the future',
]),
);
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean']));
});
it('should not accept an invalid birth date (number)', async () => {
@ -167,12 +162,7 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: 123_456 });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'birthDate must be a string in the format yyyy-MM-dd',
'Birth date cannot be in the future',
]),
);
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number']));
});
it('should not accept a birth date in the future)', async () => {
@ -180,7 +170,7 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: '9999-01-01' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['Birth date cannot be in the future']));
expect(body).toEqual(errorDto.badRequest(['[birthDate] Birth date cannot be in the future']));
});
});
@ -193,7 +183,7 @@ describe(PersonController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
it('should respond with 204', async () => {

View File

@ -27,37 +27,31 @@ describe(SearchController.name, () => {
it('should reject page as a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['page must not be less than 1', 'page must be an integer number']));
expect(body).toEqual(errorDto.badRequest(['[page] Invalid input: expected number, received string']));
});
it('should reject page as a negative number', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['page must not be less than 1']));
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
});
it('should reject page as 0', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['page must not be less than 1']));
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
});
it('should reject size as a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'size must not be greater than 1000',
'size must not be less than 1',
'size must be an integer number',
]),
);
expect(body).toEqual(errorDto.badRequest(['[size] Invalid input: expected number, received string']));
});
it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['size must not be less than 1', 'size must be an integer number']));
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
});
it('should reject an visibility as not an enum', async () => {
@ -66,7 +60,7 @@ describe(SearchController.name, () => {
.send({ visibility: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']),
errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
);
});
@ -75,7 +69,7 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isFavorite: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isFavorite must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
});
it('should reject an isEncoded as not a boolean', async () => {
@ -83,7 +77,7 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isEncoded: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isEncoded must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string']));
});
it('should reject an isOffline as not a boolean', async () => {
@ -91,13 +85,13 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isOffline: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isOffline must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string']));
});
it('should reject an isMotion as not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isMotion must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string']));
});
describe('POST /search/random', () => {
@ -111,7 +105,7 @@ describe(SearchController.name, () => {
.post('/search/random')
.send({ withStacked: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string']));
});
it('should reject if withPeople is not a boolean', async () => {
@ -119,7 +113,7 @@ describe(SearchController.name, () => {
.post('/search/random')
.send({ withPeople: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value']));
expect(body).toEqual(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string']));
});
});
@ -146,7 +140,7 @@ describe(SearchController.name, () => {
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
});
});
@ -159,7 +153,7 @@ describe(SearchController.name, () => {
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
});
});
@ -179,12 +173,7 @@ describe(SearchController.name, () => {
it('should require a type', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'type should not be empty',
expect.stringContaining('type must be one of the following values:'),
]),
);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')]));
});
});
});

View File

@ -35,9 +35,7 @@ describe(SyncController.name, () => {
.post('/sync/stream')
.send({ types: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]),
);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
expect(ctx.authenticate).toHaveBeenCalled();
});
});
@ -59,7 +57,7 @@ describe(SyncController.name, () => {
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['acks must contain no more than 1000 elements']));
expect(body).toEqual(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items']));
expect(ctx.authenticate).toHaveBeenCalled();
});
});
@ -75,9 +73,7 @@ describe(SyncController.name, () => {
.delete('/sync/ack')
.send({ types: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]),
);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
expect(ctx.authenticate).toHaveBeenCalled();
});
});

View File

@ -7,6 +7,20 @@ import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
/** Returns a full config that passes Zod validation (required URLs and min lengths). */
function validConfig() {
const config = _.cloneDeep(defaults) as typeof defaults & {
oauth: { mobileRedirectUri: string };
notifications: { smtp: { from: string; transport: { host: string } } };
server: { externalDomain: string };
};
config.oauth.mobileRedirectUri = config.oauth.mobileRedirectUri || 'https://example.com';
config.server.externalDomain = config.server.externalDomain || 'https://example.com';
config.notifications.smtp.from = config.notifications.smtp.from || 'noreply@example.com';
config.notifications.smtp.transport.host = config.notifications.smtp.transport.host || 'localhost';
return config;
}
describe(SystemConfigController.name, () => {
let ctx: ControllerContext;
const systemConfigService = mockBaseService(SystemConfigService);
@ -48,32 +62,38 @@ describe(SystemConfigController.name, () => {
describe('nightlyTasks', () => {
it('should validate nightly jobs start time', async () => {
const config = _.cloneDeep(defaults);
const config = validConfig();
config.nightlyTasks.startTime = 'invalid';
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.startTime must be in HH:mm format']));
expect(body).toEqual(
errorDto.badRequest([
'[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string',
]),
);
});
it('should accept a valid time', async () => {
const config = _.cloneDeep(defaults);
const config = validConfig();
config.nightlyTasks.startTime = '05:05';
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(200);
});
it('should validate a boolean field', async () => {
const config = _.cloneDeep(defaults);
const config = validConfig();
(config.nightlyTasks.databaseCleanup as any) = 'invalid';
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.databaseCleanup must be a boolean value']));
expect(body).toEqual(
errorDto.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']),
);
});
});
describe('image', () => {
it('should accept config without optional progressive property', async () => {
const config = _.cloneDeep(defaults);
const config = validConfig();
delete config.image.thumbnail.progressive;
delete config.image.preview.progressive;
delete config.image.fullsize.progressive;
@ -82,7 +102,7 @@ describe(SystemConfigController.name, () => {
});
it('should accept config with progressive set to true', async () => {
const config = _.cloneDeep(defaults);
const config = validConfig();
config.image.thumbnail.progressive = true;
config.image.preview.progressive = true;
config.image.fullsize.progressive = true;
@ -91,11 +111,13 @@ describe(SystemConfigController.name, () => {
});
it('should reject invalid progressive value', async () => {
const config = _.cloneDeep(defaults);
const config = validConfig();
(config.image.thumbnail.progressive as any) = 'invalid';
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['image.thumbnail.progressive must be a boolean value']));
expect(body).toEqual(
errorDto.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']),
);
});
});
});

View File

@ -54,7 +54,7 @@ describe(TagController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
});
});

View File

@ -23,6 +23,36 @@ describe(TimelineController.name, () => {
await request(ctx.getHttpServer()).get('/timeline/buckets');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should parse bbox query string into an object', async () => {
const { status } = await request(ctx.getHttpServer())
.get('/timeline/buckets')
.query({ bbox: '11.075683,49.416711,11.117589,49.454875' });
expect(status).toBe(200);
expect(service.getTimeBuckets).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
bbox: { west: 11.075_683, south: 49.416_711, east: 11.117_589, north: 49.454_875 },
}),
);
});
it('should reject incomplete bbox query string', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any),
);
});
it('should reject invalid bbox query string', async () => {
const { status, body } = await request(ctx.getHttpServer())
.get('/timeline/buckets')
.query({ bbox: '1,2,3,invalid' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[bbox] bbox parts must be valid numbers'] as any));
});
});
describe('GET /timeline/bucket', () => {

View File

@ -77,7 +77,11 @@ describe(UserAdminController.name, () => {
.set('Authorization', `Bearer token`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
);
});
it(`should not allow decimal quota`, async () => {
@ -93,7 +97,11 @@ describe(UserAdminController.name, () => {
.set('Authorization', `Bearer token`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
);
});
});
@ -116,7 +124,11 @@ describe(UserAdminController.name, () => {
.set('Authorization', `Bearer token`)
.send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
);
});
it('should allow a null pinCode', async () => {

View File

@ -103,7 +103,7 @@ export type Memory = {
showAt: Date | null;
hideAt: Date | null;
type: MemoryType;
data: object;
data: Record<string, unknown>;
ownerId: string;
isSaved: boolean;
assets: ShallowDehydrateObject<MapAsset>[];

View File

@ -1,76 +1,68 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Activity } from 'src/database';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { ValidateEnum, ValidateUUID } from 'src/validation';
export enum ReactionType {
COMMENT = 'comment',
LIKE = 'like',
}
import { mapUser, UserResponseSchema } from 'src/dtos/user.dto';
import { isoDatetimeToDate } from 'src/validation';
import * as z from 'zod';
export enum ReactionLevel {
ALBUM = 'album',
ASSET = 'asset',
}
const ReactionLevelSchema = z.enum(ReactionLevel).describe('Reaction level').meta({ id: 'ReactionLevel' });
export enum ReactionType {
COMMENT = 'comment',
LIKE = 'like',
}
const ReactionTypeSchema = z.enum(ReactionType).describe('Reaction type').meta({ id: 'ReactionType' });
export type MaybeDuplicate<T> = { duplicate: boolean; value: T };
export class ActivityResponseDto {
@ApiProperty({ description: 'Activity ID' })
id!: string;
@ApiProperty({ description: 'Creation date', format: 'date-time' })
createdAt!: Date;
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type' })
type!: ReactionType;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
user!: UserResponseDto;
@ApiProperty({ description: 'Asset ID (if activity is for an asset)' })
assetId!: string | null;
@ApiPropertyOptional({ description: 'Comment text (for comment activities)' })
comment?: string | null;
}
const ActivityResponseSchema = z
.object({
id: z.uuidv4().describe('Activity ID'),
createdAt: isoDatetimeToDate.describe('Creation date'),
user: UserResponseSchema,
assetId: z.uuidv4().nullable().describe('Asset ID (if activity is for an asset)'),
type: ReactionTypeSchema,
comment: z.string().nullish().describe('Comment text (for comment activities)'),
})
.meta({ id: 'ActivityResponseDto' });
export class ActivityStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of comments' })
comments!: number;
const ActivityStatisticsResponseSchema = z
.object({
comments: z.int().min(0).describe('Number of comments'),
likes: z.int().min(0).describe('Number of likes'),
})
.meta({ id: 'ActivityStatisticsResponseDto' });
@ApiProperty({ type: 'integer', description: 'Number of likes' })
likes!: number;
}
const ActivitySchema = z
.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
})
.describe('Activity');
export class ActivityDto {
@ValidateUUID({ description: 'Album ID' })
albumId!: string;
const ActivitySearchSchema = ActivitySchema.extend({
type: ReactionTypeSchema.optional(),
level: ReactionLevelSchema.optional(),
userId: z.uuidv4().optional().describe('Filter by user ID'),
}).describe('Activity search');
@ValidateUUID({ optional: true, description: 'Asset ID (if activity is for an asset)' })
assetId?: string;
}
export class ActivitySearchDto extends ActivityDto {
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Filter by activity type', optional: true })
type?: ReactionType;
@ValidateEnum({ enum: ReactionLevel, name: 'ReactionLevel', description: 'Filter by activity level', optional: true })
level?: ReactionLevel;
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
userId?: string;
}
const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT;
export class ActivityCreateDto extends ActivityDto {
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type (like or comment)' })
type!: ReactionType;
@ApiPropertyOptional({ description: 'Comment text (required if type is comment)' })
@ValidateIf(isComment)
@IsNotEmpty()
@IsString()
comment?: string;
}
const ActivityCreateSchema = ActivitySchema.extend({
type: ReactionTypeSchema,
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
comment: z.string().optional().describe('Comment text (required if type is comment)'),
})
.refine((data) => data.type !== ReactionType.COMMENT || (data.comment && data.comment.trim() !== ''), {
error: 'Comment is required when type is COMMENT',
path: ['comment'],
})
.refine((data) => data.type === ReactionType.COMMENT || !data.comment, {
error: 'Comment must not be provided when type is not COMMENT',
path: ['comment'],
})
.describe('Activity create');
export const mapActivity = (activity: Activity): ActivityResponseDto => {
return {
@ -82,3 +74,9 @@ export const mapActivity = (activity: Activity): ActivityResponseDto => {
user: mapUser(activity.user),
};
};
export class ActivityResponseDto extends createZodDto(ActivityResponseSchema) {}
export class ActivityCreateDto extends createZodDto(ActivityCreateSchema) {}
export class ActivityDto extends createZodDto(ActivitySchema) {}
export class ActivitySearchDto extends createZodDto(ActivitySearchSchema) {}
export class ActivityStatisticsResponseDto extends createZodDto(ActivityStatisticsResponseSchema) {}

View File

@ -1,196 +1,158 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
import { ShallowDehydrateObject } from 'kysely';
import _ from 'lodash';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseSchema, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { AlbumUserRole, AssetOrder } from 'src/enum';
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 { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
import { stringToBool } from 'src/validation';
import * as z from 'zod';
export class AlbumInfoDto {
@ValidateBoolean({ optional: true, description: 'Exclude assets from response' })
withoutAssets?: boolean;
}
export class AlbumUserAddDto {
@ValidateUUID({ description: 'User ID' })
userId!: string;
@ValidateEnum({
enum: AlbumUserRole,
name: 'AlbumUserRole',
description: 'Album user role',
default: AlbumUserRole.Editor,
const AlbumInfoSchema = z
.object({
withoutAssets: stringToBool.optional().describe('Exclude assets from response'),
})
role?: AlbumUserRole;
}
.meta({ id: 'AlbumInfoDto' });
export class AddUsersDto {
@ApiProperty({ description: 'Album users to add' })
@ArrayNotEmpty()
albumUsers!: AlbumUserAddDto[];
}
export class AlbumUserCreateDto {
@ValidateUUID({ description: 'User ID' })
userId!: string;
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
role!: AlbumUserRole;
}
export class CreateAlbumDto {
@ApiProperty({ description: 'Album name' })
@IsString()
albumName!: string;
@ApiPropertyOptional({ description: 'Album description' })
@IsString()
@Optional()
description?: string;
@ApiPropertyOptional({ description: 'Album users' })
@Optional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AlbumUserCreateDto)
albumUsers?: AlbumUserCreateDto[];
@ValidateUUID({ optional: true, each: true, description: 'Initial asset IDs' })
assetIds?: string[];
}
export class AlbumsAddAssetsDto {
@ValidateUUID({ each: true, description: 'Album IDs' })
albumIds!: string[];
@ValidateUUID({ each: true, description: 'Asset IDs' })
assetIds!: string[];
}
export class AlbumsAddAssetsResponseDto {
@ApiProperty({ description: 'Operation success' })
success!: boolean;
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', description: 'Error reason', optional: true })
error?: BulkIdErrorReason;
}
export class UpdateAlbumDto {
@ApiPropertyOptional({ description: 'Album name' })
@Optional()
@IsString()
albumName?: string;
@ApiPropertyOptional({ description: 'Album description' })
@Optional()
@IsString()
description?: string;
@ValidateUUID({ optional: true, description: 'Album thumbnail asset ID' })
albumThumbnailAssetId?: string;
@ValidateBoolean({ optional: true, description: 'Enable activity feed' })
isActivityEnabled?: boolean;
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
order?: AssetOrder;
}
export class GetAlbumsDto {
@ValidateBoolean({
optional: true,
description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums',
const AlbumUserAddSchema = z
.object({
userId: z.uuidv4().describe('User ID'),
role: AlbumUserRoleSchema.default(AlbumUserRole.Editor).optional().describe('Album user role'),
})
shared?: boolean;
.meta({ id: 'AlbumUserAddDto' });
@ValidateUUID({ optional: true, description: 'Filter albums containing this asset ID (ignores shared parameter)' })
assetId?: string;
}
const AddUsersSchema = z
.object({
albumUsers: z.array(AlbumUserAddSchema).min(1).describe('Album users to add'),
})
.meta({ id: 'AddUsersDto' });
export class AlbumStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of owned albums' })
owned!: number;
const AlbumUserCreateSchema = z
.object({
userId: z.uuidv4().describe('User ID'),
role: AlbumUserRoleSchema,
})
.meta({ id: 'AlbumUserCreateDto' });
@ApiProperty({ type: 'integer', description: 'Number of shared albums' })
shared!: number;
const CreateAlbumSchema = z
.object({
albumName: z.string().describe('Album name'),
description: z.string().optional().describe('Album description'),
albumUsers: z.array(AlbumUserCreateSchema).optional().describe('Album users'),
assetIds: z.array(z.uuidv4()).optional().describe('Initial asset IDs'),
})
.meta({ id: 'CreateAlbumDto' });
@ApiProperty({ type: 'integer', description: 'Number of non-shared albums' })
notShared!: number;
}
const AlbumsAddAssetsSchema = z
.object({
albumIds: z.array(z.uuidv4()).describe('Album IDs'),
assetIds: z.array(z.uuidv4()).describe('Asset IDs'),
})
.meta({ id: 'AlbumsAddAssetsDto' });
export class UpdateAlbumUserDto {
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
role!: AlbumUserRole;
}
const AlbumsAddAssetsResponseSchema = z
.object({
success: z.boolean().describe('Operation success'),
error: BulkIdErrorReasonSchema.optional(),
})
.meta({ id: 'AlbumsAddAssetsResponseDto' });
export class AlbumUserResponseDto {
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
user!: UserResponseDto;
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
role!: AlbumUserRole;
}
const UpdateAlbumSchema = z
.object({
albumName: z.string().optional().describe('Album name'),
description: z.string().optional().describe('Album description'),
albumThumbnailAssetId: z.uuidv4().optional().describe('Album thumbnail asset ID'),
isActivityEnabled: z.boolean().optional().describe('Enable activity feed'),
order: AssetOrderSchema.optional(),
})
.meta({ id: 'UpdateAlbumDto' });
export class ContributorCountResponseDto {
@ApiProperty({ description: 'User ID' })
userId!: string;
const GetAlbumsSchema = z
.object({
shared: stringToBool
.optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'),
})
.meta({ id: 'GetAlbumsDto' });
@ApiProperty({ type: 'integer', description: 'Number of assets contributed' })
assetCount!: number;
}
const AlbumStatisticsResponseSchema = z
.object({
owned: z.int().min(0).describe('Number of owned albums'),
shared: z.int().min(0).describe('Number of shared albums'),
notShared: z.int().min(0).describe('Number of non-shared albums'),
})
.meta({ id: 'AlbumStatisticsResponseDto' });
export class AlbumResponseDto {
@ApiProperty({ description: 'Album ID' })
id!: string;
@ApiProperty({ description: 'Owner user ID' })
ownerId!: string;
@ApiProperty({ description: 'Album name' })
albumName!: string;
@ApiProperty({ description: 'Album description' })
description!: string;
@ApiProperty({ description: 'Creation date', format: 'date-time' })
createdAt!: string;
@ApiProperty({ description: 'Last update date', format: 'date-time' })
updatedAt!: string;
@ApiProperty({ description: 'Thumbnail asset ID' })
albumThumbnailAssetId!: string | null;
@ApiProperty({ description: 'Is shared album' })
shared!: boolean;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
albumUsers!: AlbumUserResponseDto[];
@ApiProperty({ description: 'Has shared link' })
hasSharedLink!: boolean;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
assets!: AssetResponseDto[];
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
owner!: UserResponseDto;
@ApiProperty({ type: 'integer', description: 'Number of assets' })
assetCount!: number;
@ApiPropertyOptional({ description: 'Last modified asset timestamp', format: 'date-time' })
lastModifiedAssetTimestamp?: string;
@ApiPropertyOptional({ description: 'Start date (earliest asset)', format: 'date-time' })
startDate?: string;
@ApiPropertyOptional({ description: 'End date (latest asset)', format: 'date-time' })
endDate?: string;
@ApiProperty({ description: 'Activity feed enabled' })
isActivityEnabled!: boolean;
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
order?: AssetOrder;
const UpdateAlbumUserSchema = z
.object({
role: AlbumUserRoleSchema,
})
.meta({ id: 'UpdateAlbumUserDto' });
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Type(() => ContributorCountResponseDto)
contributorCounts?: ContributorCountResponseDto[];
}
const AlbumUserResponseSchema = z
.object({
user: UserResponseSchema,
role: AlbumUserRoleSchema,
})
.meta({ id: 'AlbumUserResponseDto' });
const ContributorCountResponseSchema = z
.object({
userId: z.string().describe('User ID'),
assetCount: z.int().min(0).describe('Number of assets contributed'),
})
.meta({ id: 'ContributorCountResponseDto' });
export const AlbumResponseSchema = z
.object({
id: z.string().describe('Album ID'),
ownerId: z.string().describe('Owner user ID'),
albumName: z.string().describe('Album name'),
description: z.string().describe('Album description'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
albumThumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
shared: z.boolean().describe('Is shared album'),
albumUsers: z.array(AlbumUserResponseSchema),
hasSharedLink: z.boolean().describe('Has shared link'),
assets: z.array(AssetResponseSchema),
owner: UserResponseSchema,
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
.string()
.meta({ format: 'date-time' })
.optional()
.describe('Last modified asset timestamp'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
startDate: z.string().meta({ format: 'date-time' }).optional().describe('Start date (earliest asset)'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
endDate: z.string().meta({ format: 'date-time' }).optional().describe('End date (latest asset)'),
isActivityEnabled: z.boolean().describe('Activity feed enabled'),
order: AssetOrderSchema.optional(),
contributorCounts: z.array(ContributorCountResponseSchema).optional(),
})
.meta({ id: 'AlbumResponseDto' });
export class AlbumInfoDto extends createZodDto(AlbumInfoSchema) {}
export class AddUsersDto extends createZodDto(AddUsersSchema) {}
export class AlbumUserCreateDto extends createZodDto(AlbumUserCreateSchema) {}
export class CreateAlbumDto extends createZodDto(CreateAlbumSchema) {}
export class AlbumsAddAssetsDto extends createZodDto(AlbumsAddAssetsSchema) {}
export class AlbumsAddAssetsResponseDto extends createZodDto(AlbumsAddAssetsResponseSchema) {}
export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {}
export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {}
export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {}
export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {}
export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {}
class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {}
export type MapAlbumDto = {
albumUsers?: AlbumUser[];

View File

@ -1,55 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsNotEmpty, IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Permission } from 'src/enum';
import { Optional, ValidateEnum } from 'src/validation';
import { isoDatetimeToDate } from 'src/validation';
import * as z from 'zod';
export class APIKeyCreateDto {
@ApiPropertyOptional({ description: 'API key name' })
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
const PermissionSchema = z.enum(Permission).describe('List of permissions').meta({ id: 'Permission' });
@ValidateEnum({ enum: Permission, name: 'Permission', each: true, description: 'List of permissions' })
@ArrayMinSize(1)
permissions!: Permission[];
}
export class APIKeyUpdateDto {
@ApiPropertyOptional({ description: 'API key name' })
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@ValidateEnum({
enum: Permission,
name: 'Permission',
description: 'List of permissions',
each: true,
optional: true,
const APIKeyCreateSchema = z
.object({
name: z.string().optional().describe('API key name'),
permissions: z.array(PermissionSchema).min(1).describe('List of permissions'),
})
@ArrayMinSize(1)
permissions?: Permission[];
}
.meta({ id: 'APIKeyCreateDto' });
export class APIKeyResponseDto {
@ApiProperty({ description: 'API key ID' })
id!: string;
@ApiProperty({ description: 'API key name' })
name!: string;
@ApiProperty({ description: 'Creation date' })
createdAt!: Date;
@ApiProperty({ description: 'Last update date' })
updatedAt!: Date;
@ValidateEnum({ enum: Permission, name: 'Permission', each: true, description: 'List of permissions' })
permissions!: Permission[];
}
const APIKeyUpdateSchema = z
.object({
name: z.string().optional().describe('API key name'),
permissions: z.array(PermissionSchema).min(1).optional().describe('List of permissions'),
})
.meta({ id: 'APIKeyUpdateDto' });
export class APIKeyCreateResponseDto {
@ApiProperty({ description: 'API key secret (only shown once)' })
secret!: string;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
apiKey!: APIKeyResponseDto;
}
const APIKeyResponseSchema = z
.object({
id: z.string().describe('API key ID'),
name: z.string().describe('API key name'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
permissions: z.array(PermissionSchema).describe('List of permissions'),
})
.meta({ id: 'APIKeyResponseDto' });
const APIKeyCreateResponseSchema = z
.object({
secret: z.string().describe('API key secret (only shown once)'),
apiKey: APIKeyResponseSchema,
})
.meta({ id: 'APIKeyCreateResponseDto' });
export class APIKeyCreateDto extends createZodDto(APIKeyCreateSchema) {}
export class APIKeyUpdateDto extends createZodDto(APIKeyUpdateSchema) {}
export class APIKeyResponseDto extends createZodDto(APIKeyResponseSchema) {}
export class APIKeyCreateResponseDto extends createZodDto(APIKeyCreateResponseSchema) {}

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
/** @deprecated Use `BulkIdResponseDto` instead */
export enum AssetIdErrorReason {
@ -8,15 +8,19 @@ export enum AssetIdErrorReason {
NOT_FOUND = 'not_found',
}
const AssetIdErrorReasonSchema = z
.enum(AssetIdErrorReason)
.describe('Error reason if failed')
.meta({ id: 'AssetIdErrorReason' });
/** @deprecated Use `BulkIdResponseDto` instead */
export class AssetIdsResponseDto {
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Whether operation succeeded' })
success!: boolean;
@ApiPropertyOptional({ description: 'Error reason if failed', enum: AssetIdErrorReason })
error?: AssetIdErrorReason;
}
const AssetIdsResponseSchema = z
.object({
assetId: z.string().describe('Asset ID'),
success: z.boolean().describe('Whether operation succeeded'),
error: AssetIdErrorReasonSchema.optional(),
})
.meta({ id: 'AssetIdsResponseDto' });
export enum BulkIdErrorReason {
DUPLICATE = 'duplicate',
@ -25,16 +29,26 @@ export enum BulkIdErrorReason {
UNKNOWN = 'unknown',
}
export class BulkIdsDto {
@ValidateUUID({ each: true, description: 'IDs to process' })
ids!: string[];
}
export const BulkIdErrorReasonSchema = z
.enum(BulkIdErrorReason)
.describe('Error reason')
.meta({ id: 'BulkIdErrorReason' });
export class BulkIdResponseDto {
@ApiProperty({ description: 'ID' })
id!: string;
@ApiProperty({ description: 'Whether operation succeeded' })
success!: boolean;
@ApiPropertyOptional({ description: 'Error reason if failed', enum: BulkIdErrorReason })
error?: BulkIdErrorReason;
}
export const BulkIdsSchema = z
.object({
ids: z.array(z.uuidv4()).describe('IDs to process'),
})
.meta({ id: 'BulkIdsDto' });
const BulkIdResponseSchema = z
.object({
id: z.string().describe('ID'),
success: z.boolean().describe('Whether operation succeeded'),
error: BulkIdErrorReasonSchema.optional(),
})
.meta({ id: 'BulkIdResponseDto' });
/** @deprecated Use `BulkIdResponseDto` instead */
export class AssetIdsResponseDto extends createZodDto(AssetIdsResponseSchema) {}
export class BulkIdsDto extends createZodDto(BulkIdsSchema) {}
export class BulkIdResponseDto extends createZodDto(BulkIdResponseSchema) {}

View File

@ -1,47 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ValidateEnum } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export enum AssetMediaStatus {
CREATED = 'created',
REPLACED = 'replaced',
DUPLICATE = 'duplicate',
}
export class AssetMediaResponseDto {
@ValidateEnum({ enum: AssetMediaStatus, name: 'AssetMediaStatus', description: 'Upload status' })
status!: AssetMediaStatus;
@ApiProperty({ description: 'Asset media ID' })
id!: string;
}
const AssetMediaStatusSchema = z.enum(AssetMediaStatus).describe('Upload status').meta({ id: 'AssetMediaStatus' });
const AssetMediaResponseSchema = z
.object({
status: AssetMediaStatusSchema,
id: z.string().describe('Asset media ID'),
})
.meta({ id: 'AssetMediaResponseDto' });
export enum AssetUploadAction {
ACCEPT = 'accept',
REJECT = 'reject',
}
const AssetUploadActionSchema = z.enum(AssetUploadAction).describe('Upload action').meta({ id: 'AssetUploadAction' });
export enum AssetRejectReason {
DUPLICATE = 'duplicate',
UNSUPPORTED_FORMAT = 'unsupported-format',
}
export class AssetBulkUploadCheckResult {
@ApiProperty({ description: 'Asset ID' })
id!: string;
@ApiProperty({ description: 'Upload action', enum: AssetUploadAction })
action!: AssetUploadAction;
@ApiPropertyOptional({ description: 'Rejection reason if rejected', enum: AssetRejectReason })
reason?: AssetRejectReason;
@ApiPropertyOptional({ description: 'Existing asset ID if duplicate' })
assetId?: string;
@ApiPropertyOptional({ description: 'Whether existing asset is trashed' })
isTrashed?: boolean;
}
const AssetRejectReasonSchema = z
.enum(AssetRejectReason)
.describe('Rejection reason if rejected')
.meta({ id: 'AssetRejectReason' });
export class AssetBulkUploadCheckResponseDto {
@ApiProperty({ description: 'Upload check results' })
results!: AssetBulkUploadCheckResult[];
}
const AssetBulkUploadCheckResultSchema = z
.object({
id: z.string().describe('Asset ID'),
action: AssetUploadActionSchema,
reason: AssetRejectReasonSchema.optional(),
assetId: z.string().optional().describe('Existing asset ID if duplicate'),
isTrashed: z.boolean().optional().describe('Whether existing asset is trashed'),
})
.meta({ id: 'AssetBulkUploadCheckResult' });
export class CheckExistingAssetsResponseDto {
@ApiProperty({ description: 'Existing asset IDs' })
existingIds!: string[];
}
const AssetBulkUploadCheckResponseSchema = z
.object({
results: z.array(AssetBulkUploadCheckResultSchema).describe('Upload check results'),
})
.meta({ id: 'AssetBulkUploadCheckResponseDto' });
const CheckExistingAssetsResponseSchema = z
.object({
existingIds: z.array(z.string()).describe('Existing asset IDs'),
})
.meta({ id: 'CheckExistingAssetsResponseDto' });
export class AssetMediaResponseDto extends createZodDto(AssetMediaResponseSchema) {}
export class AssetBulkUploadCheckResponseDto extends createZodDto(AssetBulkUploadCheckResponseSchema) {}
export class CheckExistingAssetsResponseDto extends createZodDto(CheckExistingAssetsResponseSchema) {}

View File

@ -1,10 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
import { AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { AssetMetadataUpsertItemSchema } from 'src/dtos/asset.dto';
import { AssetVisibilitySchema } from 'src/enum';
import { isoDatetimeToDate, JsonParsed, stringToBool } from 'src/validation';
import * as z from 'zod';
export enum AssetMediaSize {
Original = 'original',
@ -17,13 +15,14 @@ export enum AssetMediaSize {
THUMBNAIL = 'thumbnail',
}
export class AssetMediaOptionsDto {
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
size?: AssetMediaSize;
const AssetMediaSizeSchema = z.enum(AssetMediaSize).describe('Asset media size').meta({ id: 'AssetMediaSize' });
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
edited?: boolean;
}
const AssetMediaOptionsSchema = z
.object({
size: AssetMediaSizeSchema.optional(),
edited: stringToBool.default(false).optional().describe('Return edited asset if available'),
})
.meta({ id: 'AssetMediaOptionsDto' });
export enum UploadFieldName {
ASSET_DATA = 'assetData',
@ -31,98 +30,53 @@ export enum UploadFieldName {
PROFILE_DATA = 'file',
}
class AssetMediaBase {
@ApiProperty({ description: 'Device asset ID' })
@IsNotEmpty()
@IsString()
deviceAssetId!: string;
const AssetMediaBaseSchema = z.object({
deviceAssetId: z.string().describe('Device asset ID'),
deviceId: z.string().describe('Device ID'),
fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
duration: z.string().optional().describe('Duration (for videos)'),
filename: z.string().optional().describe('Filename'),
/** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */
[UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }),
});
@ApiProperty({ description: 'Device ID' })
@IsNotEmpty()
@IsString()
deviceId!: string;
const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
isFavorite: stringToBool.optional().describe('Mark as favorite'),
visibility: AssetVisibilitySchema.optional(),
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
[UploadFieldName.SIDECAR_DATA]: z
.any()
.optional()
.describe('Sidecar file data')
.meta({ type: 'string', format: 'binary' }),
}).meta({ id: 'AssetMediaCreateDto' });
@ValidateDate({ description: 'File creation date' })
fileCreatedAt!: Date;
const AssetMediaReplaceSchema = AssetMediaBaseSchema.meta({ id: 'AssetMediaReplaceDto' });
@ValidateDate({ description: 'File modification date' })
fileModifiedAt!: Date;
@ApiPropertyOptional({ description: 'Duration (for videos)' })
@Optional()
@IsString()
duration?: string;
@ApiPropertyOptional({ description: 'Filename' })
@Optional()
@IsString()
filename?: string;
// The properties below are added to correctly generate the API docs
// and client SDKs. Validation should be handled in the controller.
@ApiProperty({ type: 'string', format: 'binary', description: 'Asset file data' })
[UploadFieldName.ASSET_DATA]!: any;
}
export class AssetMediaCreateDto extends AssetMediaBase {
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
isFavorite?: boolean;
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility', optional: true })
visibility?: AssetVisibility;
@ValidateUUID({ optional: true, description: 'Live photo video ID' })
livePhotoVideoId?: string;
@ApiPropertyOptional({ description: 'Asset metadata items' })
@Transform(({ value }) => {
try {
const json = JSON.parse(value);
const items = Array.isArray(json) ? json : [json];
return items.map((item) => plainToInstance(AssetMetadataUpsertItemDto, item));
} catch {
throw new BadRequestException(['metadata must be valid JSON']);
}
const AssetBulkUploadCheckItemSchema = z
.object({
id: z.string().describe('Asset ID'),
checksum: z.string().describe('Base64 or hex encoded SHA1 hash'),
})
@Optional()
@ValidateNested({ each: true })
@IsArray()
metadata?: AssetMetadataUpsertItemDto[];
.meta({ id: 'AssetBulkUploadCheckItem' });
@ApiProperty({ type: 'string', format: 'binary', required: false, description: 'Sidecar file data' })
[UploadFieldName.SIDECAR_DATA]?: any;
}
const AssetBulkUploadCheckSchema = z
.object({
assets: z.array(AssetBulkUploadCheckItemSchema).describe('Assets to check'),
})
.meta({ id: 'AssetBulkUploadCheckDto' });
export class AssetMediaReplaceDto extends AssetMediaBase {}
const CheckExistingAssetsSchema = z
.object({
deviceAssetIds: z.array(z.string()).min(1).describe('Device asset IDs to check'),
deviceId: z.string().describe('Device ID'),
})
.meta({ id: 'CheckExistingAssetsDto' });
export class AssetBulkUploadCheckItem {
@ApiProperty({ description: 'Asset ID' })
@IsString()
@IsNotEmpty()
id!: string;
@ApiProperty({ description: 'Base64 or hex encoded SHA1 hash' })
@IsString()
@IsNotEmpty()
checksum!: string;
}
export class AssetBulkUploadCheckDto {
@ApiProperty({ description: 'Assets to check' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetBulkUploadCheckItem)
assets!: AssetBulkUploadCheckItem[];
}
export class CheckExistingAssetsDto {
@ApiProperty({ description: 'Device asset IDs to check' })
@ArrayNotEmpty()
@IsString({ each: true })
@IsNotEmpty({ each: true })
deviceAssetIds!: string[];
@ApiProperty({ description: 'Device ID' })
@IsNotEmpty()
deviceId!: string;
}
export class AssetMediaOptionsDto extends createZodDto(AssetMediaOptionsSchema) {}
export class AssetMediaCreateDto extends createZodDto(AssetMediaCreateSchema) {}
export class AssetMediaReplaceDto extends createZodDto(AssetMediaReplaceSchema) {}
export class AssetBulkUploadCheckDto extends createZodDto(AssetBulkUploadCheckSchema) {}
export class CheckExistingAssetsDto extends createZodDto(CheckExistingAssetsSchema) {}

View File

@ -1,144 +1,125 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Selectable, ShallowDehydrateObject } from 'kysely';
import { createZodDto } from 'nestjs-zod';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
import { ExifResponseSchema, mapExif } from 'src/dtos/exif.dto';
import {
AssetFaceWithoutPersonResponseDto,
AssetFaceWithoutPersonResponseSchema,
PersonWithFacesResponseDto,
PersonWithFacesResponseSchema,
mapFacesWithoutPerson,
mapPerson,
} from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { TagResponseSchema, mapTag } from 'src/dtos/tag.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetTypeSchema, AssetVisibility, AssetVisibilitySchema } from 'src/enum';
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { asDateString } from 'src/utils/date';
import { mimeTypes } from 'src/utils/mime-types';
import { ValidateEnum, ValidateUUID } from 'src/validation';
import * as z from 'zod';
export class SanitizedAssetResponseDto {
@ApiProperty({ description: 'Asset ID' })
id!: string;
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
type!: AssetType;
@ApiProperty({
description:
'Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.',
const SanitizedAssetResponseSchema = z
.object({
id: z.string().describe('Asset ID'),
type: AssetTypeSchema,
thumbhash: z
.string()
.describe(
'Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.',
)
.nullable(),
originalMimeType: z.string().optional().describe('Original MIME type'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
localDateTime: z
.string()
.meta({ format: 'date-time' })
.describe(
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
),
duration: z.string().describe('Video duration (for videos)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.number().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'),
})
thumbhash!: string | null;
@ApiPropertyOptional({ description: 'Original MIME type' })
originalMimeType?: string;
@ApiProperty({
type: 'string',
format: 'date-time',
description:
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
example: '2024-01-15T14:30:00.000Z',
})
localDateTime!: string;
@ApiProperty({ description: 'Video duration (for videos)' })
duration!: string;
@ApiPropertyOptional({ description: 'Live photo video ID' })
livePhotoVideoId?: string | null;
@ApiProperty({ description: 'Whether asset has metadata' })
hasMetadata!: boolean;
@ApiProperty({ description: 'Asset width' })
width!: number | null;
@ApiProperty({ description: 'Asset height' })
height!: number | null;
}
.meta({ id: 'SanitizedAssetResponseDto' });
export class AssetResponseDto extends SanitizedAssetResponseDto {
@ApiProperty({
type: 'string',
format: 'date-time',
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
example: '2024-01-15T20:30:00.000Z',
})
createdAt!: string;
@ApiProperty({ description: 'Device asset ID' })
deviceAssetId!: string;
@ApiProperty({ description: 'Device ID' })
deviceId!: string;
@ApiProperty({ description: 'Owner user ID' })
ownerId!: string;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
owner?: UserResponseDto;
@ValidateUUID({
nullable: true,
description: 'Library ID',
history: new HistoryBuilder().added('v1').deprecated('v1'),
})
libraryId?: string | null;
@ApiProperty({ description: 'Original file path' })
originalPath!: string;
@ApiProperty({ description: 'Original file name' })
originalFileName!: string;
@ApiProperty({
type: 'string',
format: 'date-time',
description:
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
example: '2024-01-15T19:30:00.000Z',
})
fileCreatedAt!: string;
@ApiProperty({
type: 'string',
format: 'date-time',
description:
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
example: '2024-01-16T10:15:00.000Z',
})
fileModifiedAt!: string;
@ApiProperty({
type: 'string',
format: 'date-time',
description:
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
example: '2024-01-16T12:45:30.000Z',
})
updatedAt!: string;
@ApiProperty({ description: 'Is favorite' })
isFavorite!: boolean;
@ApiProperty({ description: 'Is archived' })
isArchived!: boolean;
@ApiProperty({ description: 'Is trashed' })
isTrashed!: boolean;
@ApiProperty({ description: 'Is offline' })
isOffline!: boolean;
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility' })
visibility!: AssetVisibility;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
exifInfo?: ExifResponseDto;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
tags?: TagResponseDto[];
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
people?: PersonWithFacesResponseDto[];
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
@ApiProperty({ description: 'Base64 encoded SHA1 hash' })
checksum!: string;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
stack?: AssetStackResponseDto | null;
@ApiPropertyOptional({ description: 'Duplicate group ID' })
duplicateId?: string | null;
export class SanitizedAssetResponseDto extends createZodDto(SanitizedAssetResponseSchema) {}
@Property({ description: 'Is resized', history: new HistoryBuilder().added('v1').deprecated('v1.113.0') })
resized?: boolean;
@Property({ description: 'Is edited', history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0') })
isEdited!: boolean;
}
const AssetStackResponseSchema = z
.object({
id: z.string().describe('Stack ID'),
primaryAssetId: z.string().describe('Primary asset ID'),
assetCount: z.int().min(0).describe('Number of assets in stack'),
})
.meta({ id: 'AssetStackResponseDto' });
export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
z.object({
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z
.string()
.meta({ format: 'date-time' })
.describe('The UTC timestamp when the asset was originally uploaded to Immich.'),
deviceAssetId: z.string().describe('Device asset ID'),
deviceId: z.string().describe('Device ID'),
ownerId: z.string().describe('Owner user ID'),
owner: UserResponseSchema.optional(),
libraryId: z
.uuidv4()
.nullish()
.describe('Library ID')
.meta({ ...new HistoryBuilder().added('v1').deprecated('v1').getExtensions() }),
originalPath: z.string().describe('Original file path'),
originalFileName: z.string().describe('Original file name'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
fileCreatedAt: z
.string()
.meta({ format: 'date-time' })
.describe(
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
),
fileModifiedAt: z
.string()
.meta({ format: 'date-time' })
.describe(
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
),
updatedAt: z
.string()
.meta({ format: 'date-time' })
.describe(
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
),
isFavorite: z.boolean().describe('Is favorite'),
isArchived: z.boolean().describe('Is archived'),
isTrashed: z.boolean().describe('Is trashed'),
isOffline: z.boolean().describe('Is offline'),
visibility: AssetVisibilitySchema,
exifInfo: ExifResponseSchema.optional(),
tags: z.array(TagResponseSchema).optional(),
people: z.array(PersonWithFacesResponseSchema).optional(),
unassignedFaces: z.array(AssetFaceWithoutPersonResponseSchema).optional(),
checksum: z.string().describe('Base64 encoded SHA1 hash'),
stack: AssetStackResponseSchema.nullish(),
duplicateId: z.string().nullish().describe('Duplicate group ID'),
resized: z
.boolean()
.optional()
.describe('Is resized')
.meta({ ...new HistoryBuilder().added('v1').deprecated('v1.113.0').getExtensions() }),
isEdited: z
.boolean()
.describe('Is edited')
.meta({ ...new HistoryBuilder().added('v2.5.0').stable('v2.5.0').getExtensions() }),
}).shape,
).meta({ id: 'AssetResponseDto' });
export class AssetResponseDto extends createZodDto(AssetResponseSchema) {}
export type MapAsset = {
createdAt: Date;
@ -179,17 +160,6 @@ export type MapAsset = {
isEdited: boolean;
};
export class AssetStackResponseDto {
@ApiProperty({ description: 'Stack ID' })
id!: string;
@ApiProperty({ description: 'Primary asset ID' })
primaryAssetId!: string;
@ApiProperty({ type: 'integer', description: 'Number of assets in stack' })
assetCount!: number;
}
export type AssetMapOptions = {
stripMetadata?: boolean;
withStack?: boolean;

View File

@ -1,125 +1,78 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsInt,
IsLatitude,
IsLongitude,
IsNotEmpty,
IsObject,
IsPositive,
IsString,
IsTimeZone,
Max,
Min,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { HistoryBuilder, Property } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { createZodDto } from 'nestjs-zod';
import { HistoryBuilder } from 'src/decorators';
import { BulkIdsSchema } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibilitySchema } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
import { IsNotSiblingOf, isoDatetimeToDate, latitudeSchema, longitudeSchema, stringToBool } from 'src/validation';
import * as z from 'zod';
export class DeviceIdDto {
@ApiProperty({ description: 'Device ID' })
@IsNotEmpty()
@IsString()
deviceId!: string;
}
const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
o.latitude !== undefined || o.longitude !== undefined;
const ValidateGPS = () => ValidateIf(hasGPS);
export class UpdateAssetBase {
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
isFavorite?: boolean;
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true, description: 'Asset visibility' })
visibility?: AssetVisibility;
@ApiProperty({ description: 'Original date and time' })
@Optional()
@IsDateString()
dateTimeOriginal?: string;
@ApiProperty({ description: 'Latitude coordinate' })
@ValidateGPS()
@IsLatitude()
@IsNotEmpty()
latitude?: number;
@ApiProperty({ description: 'Longitude coordinate' })
@ValidateGPS()
@IsLongitude()
@IsNotEmpty()
longitude?: number;
@Property({
description: 'Rating in range [1-5], or null for unrated',
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
const DeviceIdSchema = z
.object({
deviceId: z.string().describe('Device ID'),
})
@Optional({ nullable: true })
@IsInt()
@Max(5)
@Min(-1)
@Transform(({ value }) => (value === 0 ? null : value))
rating?: number | null;
.meta({ id: 'DeviceIdDto' });
@ApiProperty({ description: 'Asset description' })
@Optional()
@IsString()
description?: string;
}
const UpdateAssetBaseSchema = z
.object({
isFavorite: z.boolean().optional().describe('Mark as favorite'),
visibility: AssetVisibilitySchema.optional(),
dateTimeOriginal: z.string().optional().describe('Original date and time'),
latitude: latitudeSchema.optional().describe('Latitude coordinate'),
longitude: longitudeSchema.optional().describe('Longitude coordinate'),
rating: z
.number()
.int()
.min(-1)
.max(5)
.transform((value) => (value === 0 ? null : value))
.nullish()
.describe('Rating in range [1-5], or null for unrated')
.meta({
...new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
.getExtensions(),
}),
description: z.string().optional().describe('Asset description'),
})
.refine(
(data) =>
(data.latitude === undefined && data.longitude === undefined) ||
(data.latitude !== undefined && data.longitude !== undefined),
{ message: 'Latitude and longitude must be provided together' },
);
export class AssetBulkUpdateDto extends UpdateAssetBase {
@ValidateUUID({ each: true, description: 'Asset IDs to update' })
ids!: string[];
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
});
@ValidateString({ optional: true, nullable: true, description: 'Duplicate ID' })
duplicateId?: string | null;
const AssetBulkUpdateSchema = AssetBulkUpdateBaseSchema.pipe(
IsNotSiblingOf(AssetBulkUpdateBaseSchema, 'dateTimeRelative', ['dateTimeOriginal']),
).meta({ id: 'AssetBulkUpdateDto' });
@ApiProperty({ description: 'Relative time offset in seconds' })
@IsNotSiblingOf(['dateTimeOriginal'])
@Optional()
@IsInt()
dateTimeRelative?: number;
const UpdateAssetSchema = UpdateAssetBaseSchema.extend({
livePhotoVideoId: z.uuidv4().nullish().describe('Live photo video ID'),
}).meta({ id: 'UpdateAssetDto' });
@ApiProperty({ description: 'Time zone (IANA timezone)' })
@IsNotSiblingOf(['dateTimeOriginal'])
@IsTimeZone()
@Optional()
timeZone?: string;
}
const RandomAssetsSchema = z
.object({
count: z.coerce.number().min(1).optional().describe('Number of random assets to return'),
})
.meta({ id: 'RandomAssetsDto' });
export class UpdateAssetDto extends UpdateAssetBase {
@ValidateUUID({ optional: true, nullable: true, description: 'Live photo video ID' })
livePhotoVideoId?: string | null;
}
const AssetBulkDeleteSchema = BulkIdsSchema.extend({
force: z.boolean().optional().describe('Force delete even if in use'),
}).meta({ id: 'AssetBulkDeleteDto' });
export class RandomAssetsDto {
@ApiProperty({ description: 'Number of random assets to return' })
@Optional()
@IsInt()
@IsPositive()
@Type(() => Number)
count?: number;
}
export class AssetBulkDeleteDto extends BulkIdsDto {
@ValidateBoolean({ optional: true, description: 'Force delete even if in use' })
force?: boolean;
}
export class AssetIdsDto {
@ValidateUUID({ each: true, description: 'Asset IDs' })
assetIds!: string[];
}
export const AssetIdsSchema = z
.object({
assetIds: z.array(z.uuidv4()).describe('Asset IDs'),
})
.meta({ id: 'AssetIdsDto' });
export enum AssetJobName {
REFRESH_FACES = 'refresh-faces',
@ -128,137 +81,104 @@ export enum AssetJobName {
TRANSCODE_VIDEO = 'transcode-video',
}
export class AssetJobsDto extends AssetIdsDto {
@ValidateEnum({ enum: AssetJobName, name: 'AssetJobName', description: 'Job name' })
name!: AssetJobName;
}
const AssetJobNameSchema = z.enum(AssetJobName).describe('Job name').meta({ id: 'AssetJobName' });
export class AssetStatsDto {
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Filter by visibility', optional: true })
visibility?: AssetVisibility;
const AssetJobsSchema = AssetIdsSchema.extend({
name: AssetJobNameSchema,
}).meta({ id: 'AssetJobsDto' });
@ValidateBoolean({ optional: true, description: 'Filter by favorite status' })
isFavorite?: boolean;
const AssetStatsSchema = z
.object({
visibility: AssetVisibilitySchema.optional(),
isFavorite: stringToBool.optional().describe('Filter by favorite status'),
isTrashed: stringToBool.optional().describe('Filter by trash status'),
})
.meta({ id: 'AssetStatsDto' });
@ValidateBoolean({ optional: true, description: 'Filter by trash status' })
isTrashed?: boolean;
}
const AssetStatsResponseSchema = z
.object({
images: z.int().describe('Number of images'),
videos: z.int().describe('Number of videos'),
total: z.int().describe('Total number of assets'),
})
.meta({ id: 'AssetStatsResponseDto' });
export class AssetStatsResponseDto {
@ApiProperty({ description: 'Number of images', type: 'integer' })
images!: number;
const AssetMetadataRouteParamsSchema = z
.object({
id: z.uuidv4().describe('Asset ID'),
key: z.string().describe('Metadata key'),
})
.meta({ id: 'AssetMetadataRouteParams' });
@ApiProperty({ description: 'Number of videos', type: 'integer' })
videos!: number;
export const AssetMetadataUpsertItemSchema = z
.object({
key: z.string().describe('Metadata key'),
value: z.record(z.string(), z.unknown()).describe('Metadata value (object)'),
})
.meta({ id: 'AssetMetadataUpsertItemDto' });
@ApiProperty({ description: 'Total number of assets', type: 'integer' })
total!: number;
}
const AssetMetadataUpsertSchema = z
.object({
items: z.array(AssetMetadataUpsertItemSchema).describe('Metadata items to upsert'),
})
.meta({ id: 'AssetMetadataUpsertDto' });
export class AssetMetadataRouteParams {
@ValidateUUID({ description: 'Asset ID' })
id!: string;
const AssetMetadataBulkUpsertItemSchema = z
.object({
assetId: z.uuidv4().describe('Asset ID'),
key: z.string().describe('Metadata key'),
value: z.record(z.string(), z.unknown()).describe('Metadata value (object)'),
})
.meta({ id: 'AssetMetadataBulkUpsertItemDto' });
@ValidateString({ description: 'Metadata key' })
key!: string;
}
const AssetMetadataBulkUpsertSchema = z
.object({
items: z.array(AssetMetadataBulkUpsertItemSchema).describe('Metadata items to upsert'),
})
.meta({ id: 'AssetMetadataBulkUpsertDto' });
export class AssetMetadataUpsertDto {
@ApiProperty({ description: 'Metadata items to upsert' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataUpsertItemDto)
items!: AssetMetadataUpsertItemDto[];
}
const AssetMetadataBulkDeleteItemSchema = z
.object({
assetId: z.uuidv4().describe('Asset ID'),
key: z.string().describe('Metadata key'),
})
.meta({ id: 'AssetMetadataBulkDeleteItemDto' });
export class AssetMetadataUpsertItemDto {
@ValidateString({ description: 'Metadata key' })
key!: string;
const AssetMetadataBulkDeleteSchema = z
.object({
items: z.array(AssetMetadataBulkDeleteItemSchema).describe('Metadata items to delete'),
})
.meta({ id: 'AssetMetadataBulkDeleteDto' });
@ApiProperty({ description: 'Metadata value (object)' })
@IsObject()
value!: object;
}
const AssetMetadataResponseSchema = z
.object({
key: z.string().describe('Metadata key'),
value: z.record(z.string(), z.unknown()).describe('Metadata value (object)'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
})
.meta({ id: 'AssetMetadataResponseDto' });
export class AssetMetadataBulkUpsertDto {
@ApiProperty({ description: 'Metadata items to upsert' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataBulkUpsertItemDto)
items!: AssetMetadataBulkUpsertItemDto[];
}
const AssetMetadataBulkResponseSchema = AssetMetadataResponseSchema.extend({
assetId: z.string().describe('Asset ID'),
}).meta({ id: 'AssetMetadataBulkResponseDto' });
export class AssetMetadataBulkUpsertItemDto {
@ValidateUUID({ description: 'Asset ID' })
assetId!: string;
const AssetCopySchema = z
.object({
sourceId: z.uuidv4().describe('Source asset ID'),
targetId: z.uuidv4().describe('Target asset ID'),
sharedLinks: z.boolean().default(true).optional().describe('Copy shared links'),
albums: z.boolean().default(true).optional().describe('Copy album associations'),
sidecar: z.boolean().default(true).optional().describe('Copy sidecar file'),
stack: z.boolean().default(true).optional().describe('Copy stack association'),
favorite: z.boolean().default(true).optional().describe('Copy favorite status'),
})
.meta({ id: 'AssetCopyDto' });
@ValidateString({ description: 'Metadata key' })
key!: string;
@ApiProperty({ description: 'Metadata value (object)' })
@IsObject()
value!: object;
}
export class AssetMetadataBulkDeleteDto {
@ApiProperty({ description: 'Metadata items to delete' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataBulkDeleteItemDto)
items!: AssetMetadataBulkDeleteItemDto[];
}
export class AssetMetadataBulkDeleteItemDto {
@ValidateUUID({ description: 'Asset ID' })
assetId!: string;
@ValidateString({ description: 'Metadata key' })
key!: string;
}
export class AssetMetadataResponseDto {
@ValidateString({ description: 'Metadata key' })
key!: string;
@ApiProperty({ description: 'Metadata value (object)' })
value!: object;
@ApiProperty({ description: 'Last update date' })
updatedAt!: Date;
}
export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto {
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
}
export class AssetCopyDto {
@ValidateUUID({ description: 'Source asset ID' })
sourceId!: string;
@ValidateUUID({ description: 'Target asset ID' })
targetId!: string;
@ValidateBoolean({ optional: true, description: 'Copy shared links', default: true })
sharedLinks?: boolean;
@ValidateBoolean({ optional: true, description: 'Copy album associations', default: true })
albums?: boolean;
@ValidateBoolean({ optional: true, description: 'Copy sidecar file', default: true })
sidecar?: boolean;
@ValidateBoolean({ optional: true, description: 'Copy stack association', default: true })
stack?: boolean;
@ValidateBoolean({ optional: true, description: 'Copy favorite status', default: true })
favorite?: boolean;
}
export class AssetDownloadOriginalDto {
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
edited?: boolean;
}
const AssetDownloadOriginalSchema = z
.object({
edited: stringToBool.default(false).optional().describe('Return edited asset if available'),
})
.meta({ id: 'AssetDownloadOriginalDto' });
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
@ -267,3 +187,21 @@ export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
total: Object.values(stats).reduce((total, value) => total + value, 0),
};
};
export class DeviceIdDto extends createZodDto(DeviceIdSchema) {}
export class AssetBulkUpdateDto extends createZodDto(AssetBulkUpdateSchema) {}
export class UpdateAssetDto extends createZodDto(UpdateAssetSchema) {}
export class RandomAssetsDto extends createZodDto(RandomAssetsSchema) {}
export class AssetBulkDeleteDto extends createZodDto(AssetBulkDeleteSchema) {}
export class AssetIdsDto extends createZodDto(AssetIdsSchema) {}
export class AssetJobsDto extends createZodDto(AssetJobsSchema) {}
export class AssetStatsDto extends createZodDto(AssetStatsSchema) {}
export class AssetStatsResponseDto extends createZodDto(AssetStatsResponseSchema) {}
export class AssetMetadataRouteParams extends createZodDto(AssetMetadataRouteParamsSchema) {}
export class AssetMetadataUpsertDto extends createZodDto(AssetMetadataUpsertSchema) {}
export class AssetMetadataBulkUpsertDto extends createZodDto(AssetMetadataBulkUpsertSchema) {}
export class AssetMetadataBulkDeleteDto extends createZodDto(AssetMetadataBulkDeleteSchema) {}
export class AssetMetadataResponseDto extends createZodDto(AssetMetadataResponseSchema) {}
export class AssetMetadataBulkResponseDto extends createZodDto(AssetMetadataBulkResponseSchema) {}
export class AssetCopyDto extends createZodDto(AssetCopySchema) {}
export class AssetDownloadOriginalDto extends createZodDto(AssetDownloadOriginalSchema) {}

View File

@ -1,59 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { ImmichCookie, UserMetadataKey } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { Optional, PinCode, toEmail, ValidateBoolean } from 'src/validation';
import { toEmail } from 'src/validation';
import * as z from 'zod';
export type CookieResponse = {
isSecure: boolean;
values: Array<{ key: ImmichCookie; value: string | null }>;
};
export class AuthDto {
@ApiProperty({ description: 'Authenticated user' })
user!: AuthUser;
export const pinCodeRegex = /^\d{6}$/;
@ApiPropertyOptional({ description: 'API key (if authenticated via API key)' })
export type AuthDto = {
user: AuthUser;
apiKey?: AuthApiKey;
@ApiPropertyOptional({ description: 'Shared link (if authenticated via shared link)' })
sharedLink?: AuthSharedLink;
@ApiPropertyOptional({ description: 'Session (if authenticated via session)' })
session?: AuthSession;
}
};
export class LoginCredentialDto {
@ApiProperty({ example: 'testuser@email.com', description: 'User email' })
@IsEmail({ require_tld: false })
@Transform(toEmail)
@IsNotEmpty()
email!: string;
const LoginCredentialSchema = z
.object({
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
password: z.string().describe('User password').meta({ example: 'password' }),
})
.meta({ id: 'LoginCredentialDto' });
@ApiProperty({ example: 'password', description: 'User password' })
@IsString()
@IsNotEmpty()
password!: string;
}
export class LoginResponseDto {
@ApiProperty({ description: 'Access token' })
accessToken!: string;
@ApiProperty({ description: 'User ID' })
userId!: string;
@ApiProperty({ description: 'User email' })
userEmail!: string;
@ApiProperty({ description: 'User name' })
name!: string;
@ApiProperty({ description: 'Profile image path' })
profileImagePath!: string;
@ApiProperty({ description: 'Is admin user' })
isAdmin!: boolean;
@ApiProperty({ description: 'Should change password' })
shouldChangePassword!: boolean;
@ApiProperty({ description: 'Is onboarded' })
isOnboarded!: boolean;
}
const LoginResponseSchema = z
.object({
accessToken: z.string().describe('Access token'),
userId: z.string().describe('User ID'),
userEmail: toEmail.describe('User email'),
name: z.string().describe('User name'),
profileImagePath: z.string().describe('Profile image path'),
isAdmin: z.boolean().describe('Is admin user'),
shouldChangePassword: z.boolean().describe('Should change password'),
isOnboarded: z.boolean().describe('Is onboarded'),
})
.meta({ id: 'LoginResponseDto' });
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
const onboardingMetadata = entity.metadata.find(
@ -72,115 +56,95 @@ export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginR
};
}
export class LogoutResponseDto {
@ApiProperty({ description: 'Logout successful' })
successful!: boolean;
@ApiProperty({ description: 'Redirect URI' })
redirectUri!: string;
}
const LogoutResponseSchema = z
.object({
successful: z.boolean().describe('Logout successful'),
redirectUri: z.string().describe('Redirect URI'),
})
.meta({ id: 'LogoutResponseDto' });
export class SignUpDto extends LoginCredentialDto {
@ApiProperty({ example: 'Admin', description: 'User name' })
@IsString()
@IsNotEmpty()
name!: string;
}
const SignUpSchema = LoginCredentialSchema.extend({
name: z.string().describe('User name').meta({ example: 'Admin' }),
}).meta({ id: 'SignUpDto' });
export class ChangePasswordDto {
@ApiProperty({ example: 'password', description: 'Current password' })
@IsString()
@IsNotEmpty()
password!: string;
const ChangePasswordSchema = z
.object({
password: z.string().describe('Current password').meta({ example: 'password' }),
newPassword: z.string().min(8).describe('New password (min 8 characters)').meta({ example: 'password' }),
invalidateSessions: z.boolean().default(false).optional().describe('Invalidate all other sessions'),
})
.meta({ id: 'ChangePasswordDto' });
@ApiProperty({ example: 'password', description: 'New password (min 8 characters)' })
@IsString()
@IsNotEmpty()
@MinLength(8)
newPassword!: string;
const PinCodeSetupSchema = z
.object({
pinCode: z.string().regex(pinCodeRegex).describe('PIN code (4-6 digits)').meta({ example: '123456' }),
})
.meta({ id: 'PinCodeSetupDto' });
@ValidateBoolean({ optional: true, default: false, description: 'Invalidate all other sessions' })
invalidateSessions?: boolean;
}
const PinCodeResetSchema = z.object({
pinCode: z.string().regex(pinCodeRegex).optional().describe('New PIN code (4-6 digits)').meta({ example: '123456' }),
password: z
.string()
.optional()
.describe('User password (required if PIN code is not provided)')
.meta({ example: 'password' }),
});
export class PinCodeSetupDto {
@ApiProperty({ description: 'PIN code (4-6 digits)' })
@PinCode()
pinCode!: string;
}
const SessionUnlockSchema = PinCodeResetSchema.meta({ id: 'SessionUnlockDto' });
export class PinCodeResetDto {
@ApiPropertyOptional({ description: 'New PIN code (4-6 digits)' })
@PinCode({ optional: true })
pinCode?: string;
const PinCodeChangeSchema = PinCodeResetSchema.extend({
newPinCode: z.string().regex(pinCodeRegex).describe('New PIN code (4-6 digits)'),
}).meta({ id: 'PinCodeChangeDto' });
@ApiPropertyOptional({ description: 'User password (required if PIN code is not provided)' })
@Optional()
@IsString()
@IsNotEmpty()
password?: string;
}
const ValidateAccessTokenResponseSchema = z
.object({
authStatus: z.boolean().describe('Authentication status'),
})
.meta({ id: 'ValidateAccessTokenResponseDto' });
export class SessionUnlockDto extends PinCodeResetDto {}
const OAuthCallbackSchema = z
.object({
url: z.string().min(1).describe('OAuth callback URL'),
state: z.string().optional().describe('OAuth state parameter'),
codeVerifier: z.string().optional().describe('OAuth code verifier (PKCE)'),
})
.meta({ id: 'OAuthCallbackDto' });
export class PinCodeChangeDto extends PinCodeResetDto {
@ApiProperty({ description: 'New PIN code (4-6 digits)' })
@PinCode()
newPinCode!: string;
}
const OAuthConfigSchema = z
.object({
redirectUri: z.string().describe('OAuth redirect URI'),
state: z.string().optional().describe('OAuth state parameter'),
codeChallenge: z.string().optional().describe('OAuth code challenge (PKCE)'),
})
.meta({ id: 'OAuthConfigDto' });
export class ValidateAccessTokenResponseDto {
@ApiProperty({ description: 'Authentication status' })
authStatus!: boolean;
}
const OAuthAuthorizeResponseSchema = z
.object({
url: z.string().describe('OAuth authorization URL'),
})
.meta({ id: 'OAuthAuthorizeResponseDto' });
export class OAuthCallbackDto {
@ApiProperty({ description: 'OAuth callback URL' })
@IsNotEmpty()
@IsString()
url!: string;
const AuthStatusResponseSchema = z
.object({
pinCode: z.boolean().describe('Has PIN code set'),
password: z.boolean().describe('Has password set'),
isElevated: z.boolean().describe('Is elevated session'),
expiresAt: z.string().optional().describe('Session expiration date'),
pinExpiresAt: z.string().optional().describe('PIN expiration date'),
})
.meta({ id: 'AuthStatusResponseDto' });
@ApiPropertyOptional({ description: 'OAuth state parameter' })
@Optional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'OAuth code verifier (PKCE)' })
@Optional()
@IsString()
codeVerifier?: string;
}
export class OAuthConfigDto {
@ApiProperty({ description: 'OAuth redirect URI' })
@IsNotEmpty()
@IsString()
redirectUri!: string;
@ApiPropertyOptional({ description: 'OAuth state parameter' })
@Optional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'OAuth code challenge (PKCE)' })
@Optional()
@IsString()
codeChallenge?: string;
}
export class OAuthAuthorizeResponseDto {
@ApiProperty({ description: 'OAuth authorization URL' })
url!: string;
}
export class AuthStatusResponseDto {
@ApiProperty({ description: 'Has PIN code set' })
pinCode!: boolean;
@ApiProperty({ description: 'Has password set' })
password!: boolean;
@ApiProperty({ description: 'Is elevated session' })
isElevated!: boolean;
@ApiPropertyOptional({ description: 'Session expiration date' })
expiresAt?: string;
@ApiPropertyOptional({ description: 'PIN expiration date' })
pinExpiresAt?: string;
}
export class LoginCredentialDto extends createZodDto(LoginCredentialSchema) {}
export class LoginResponseDto extends createZodDto(LoginResponseSchema) {}
export class LogoutResponseDto extends createZodDto(LogoutResponseSchema) {}
export class SignUpDto extends createZodDto(SignUpSchema) {}
export class ChangePasswordDto extends createZodDto(ChangePasswordSchema) {}
export class PinCodeSetupDto extends createZodDto(PinCodeSetupSchema) {}
export class PinCodeResetDto extends createZodDto(PinCodeResetSchema) {}
export class SessionUnlockDto extends createZodDto(SessionUnlockSchema) {}
export class PinCodeChangeDto extends createZodDto(PinCodeChangeSchema) {}
export class ValidateAccessTokenResponseDto extends createZodDto(ValidateAccessTokenResponseSchema) {}
export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}

View File

@ -1,25 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude } from 'class-validator';
import { IsGreaterThanOrEqualTo } from 'src/validation';
import { latitudeSchema, longitudeSchema } from 'src/validation';
import * as z from 'zod';
export class BBoxDto {
@ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' })
@IsLongitude()
west!: number;
@ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' })
@IsLatitude()
south!: number;
@ApiProperty({
format: 'double',
description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.',
export const BBoxSchema = z
.object({
west: longitudeSchema.describe('West longitude (-180 to 180)'),
south: latitudeSchema.describe('South latitude (-90 to 90)'),
east: longitudeSchema.describe(
'East longitude (-180 to 180). May be less than west when crossing the antimeridian.',
),
north: latitudeSchema.describe('North latitude (-90 to 90). Must be >= south.'),
})
@IsLongitude()
east!: number;
@ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' })
@IsLatitude()
@IsGreaterThanOrEqualTo('south')
north!: number;
}
.refine(({ north, south }) => north >= south, {
path: ['north'],
error: 'North latitude must be greater than or equal to south latitude',
})
.meta({ id: 'BBoxDto' });

View File

@ -1,21 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export class DatabaseBackupDto {
filename!: string;
filesize!: number;
}
const DatabaseBackupSchema = z
.object({
filename: z.string().describe('Backup filename'),
filesize: z.number().describe('Backup file size'),
})
.meta({ id: 'DatabaseBackupDto' });
export class DatabaseBackupListResponseDto {
backups!: DatabaseBackupDto[];
}
const DatabaseBackupListResponseSchema = z
.object({
backups: z.array(DatabaseBackupSchema).describe('List of backups'),
})
.meta({ id: 'DatabaseBackupListResponseDto' });
export class DatabaseBackupUploadDto {
@ApiProperty({ type: 'string', format: 'binary', required: false })
file?: any;
}
const DatabaseBackupUploadSchema = z
.object({
file: z.file().optional().describe('Database backup file'),
})
.meta({ id: 'DatabaseBackupUploadDto' });
export class DatabaseBackupDeleteDto {
@IsString({ each: true })
backups!: string[];
}
const DatabaseBackupDeleteSchema = z
.object({
backups: z.array(z.string()).describe('Backup filenames to delete'),
})
.meta({ id: 'DatabaseBackupDeleteDto' });
export class DatabaseBackupListResponseDto extends createZodDto(DatabaseBackupListResponseSchema) {}
export class DatabaseBackupUploadDto extends createZodDto(DatabaseBackupUploadSchema) {}
export class DatabaseBackupDeleteDto extends createZodDto(DatabaseBackupDeleteSchema) {}

View File

@ -1,40 +1,35 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsPositive } from 'class-validator';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { AssetIdsSchema } from 'src/dtos/asset.dto';
import * as z from 'zod';
export class DownloadInfoDto {
@ValidateUUID({ each: true, optional: true, description: 'Asset IDs to download' })
assetIds?: string[];
const DownloadInfoSchema = z
.object({
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs to download'),
albumId: z.uuidv4().optional().describe('Album ID to download'),
userId: z.uuidv4().optional().describe('User ID to download assets from'),
archiveSize: z.int().min(1).optional().describe('Archive size limit in bytes'),
})
.meta({ id: 'DownloadInfoDto' });
@ValidateUUID({ optional: true, description: 'Album ID to download' })
albumId?: string;
const DownloadArchiveInfoSchema = z
.object({
size: z.int().describe('Archive size in bytes'),
assetIds: z.array(z.string()).describe('Asset IDs in this archive'),
})
.meta({ id: 'DownloadArchiveInfo' });
@ValidateUUID({ optional: true, description: 'User ID to download assets from' })
userId?: string;
const DownloadResponseSchema = z
.object({
totalSize: z.int().describe('Total size in bytes'),
archives: z.array(DownloadArchiveInfoSchema).describe('Archive information'),
})
.meta({ id: 'DownloadResponseDto' });
@ApiPropertyOptional({ type: 'integer', description: 'Archive size limit in bytes' })
@IsInt()
@IsPositive()
@Optional()
archiveSize?: number;
}
const DownloadArchiveSchema = AssetIdsSchema.extend({
edited: z.boolean().optional().describe('Download edited asset if available'),
}).meta({ id: 'DownloadArchiveDto' });
export class DownloadResponseDto {
@ApiProperty({ type: 'integer', description: 'Total size in bytes' })
totalSize!: number;
@ApiProperty({ description: 'Archive information' })
archives!: DownloadArchiveInfo[];
}
export class DownloadArchiveInfo {
@ApiProperty({ type: 'integer', description: 'Archive size in bytes' })
size!: number;
@ApiProperty({ description: 'Asset IDs in this archive' })
assetIds!: string[];
}
export class DownloadArchiveDto extends AssetIdsDto {
@ValidateBoolean({ optional: true, description: 'Download edited asset if available' })
edited?: boolean;
}
export class DownloadInfoDto extends createZodDto(DownloadInfoSchema) {}
export class DownloadResponseDto extends createZodDto(DownloadResponseSchema) {}
export class DownloadArchiveInfo extends createZodDto(DownloadArchiveInfoSchema) {}
export class DownloadArchiveDto extends createZodDto(DownloadArchiveSchema) {}

View File

@ -1,9 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { createZodDto } from 'nestjs-zod';
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
import * as z from 'zod';
export class DuplicateResponseDto {
@ApiProperty({ description: 'Duplicate group ID' })
duplicateId!: string;
@ApiProperty({ description: 'Duplicate assets' })
assets!: AssetResponseDto[];
}
const DuplicateResponseSchema = z
.object({
duplicateId: z.string().describe('Duplicate group ID'),
assets: z.array(AssetResponseSchema).describe('Duplicate assets'),
})
.meta({ id: 'DuplicateResponseDto' });
export class DuplicateResponseDto extends createZodDto(DuplicateResponseSchema) {}

View File

@ -1,7 +1,5 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export enum AssetEditAction {
Crop = 'crop',
@ -9,103 +7,128 @@ export enum AssetEditAction {
Mirror = 'mirror',
}
export const AssetEditActionSchema = z
.enum(AssetEditAction)
.describe('Type of edit action to perform')
.meta({ id: 'AssetEditAction' });
export enum MirrorAxis {
Horizontal = 'horizontal',
Vertical = 'vertical',
}
export class CropParameters {
@IsInt()
@Min(0)
@ApiProperty({ description: 'Top-Left X coordinate of crop' })
x!: number;
const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mirror along').meta({ id: 'MirrorAxis' });
@IsInt()
@Min(0)
@ApiProperty({ description: 'Top-Left Y coordinate of crop' })
y!: number;
@IsInt()
@Min(1)
@ApiProperty({ description: 'Width of the crop' })
width!: number;
@IsInt()
@Min(1)
@ApiProperty({ description: 'Height of the crop' })
height!: number;
}
export class RotateParameters {
@IsAxisAlignedRotation()
@ApiProperty({ description: 'Rotation angle in degrees' })
angle!: number;
}
export class MirrorParameters {
@IsEnum(MirrorAxis)
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
axis!: MirrorAxis;
}
export type AssetEditParameters = CropParameters | RotateParameters | MirrorParameters;
export type AssetEditActionItem =
| {
action: AssetEditAction.Crop;
parameters: CropParameters;
}
| {
action: AssetEditAction.Rotate;
parameters: RotateParameters;
}
| {
action: AssetEditAction.Mirror;
parameters: MirrorParameters;
};
@ApiExtraModels(CropParameters, RotateParameters, MirrorParameters)
export class AssetEditActionItemDto {
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
action!: AssetEditAction;
@ApiProperty({
description: 'List of edit actions to apply (crop, rotate, or mirror)',
anyOf: [CropParameters, RotateParameters, MirrorParameters].map((type) => ({
$ref: getSchemaPath(type),
})),
const CropParametersSchema = z
.object({
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'),
})
@ValidateNested()
@Type((options) => actionParameterMap[options?.object.action as keyof AssetEditActionParameter])
parameters!: AssetEditActionItem['parameters'];
}
.meta({ id: 'CropParameters' });
export class AssetEditActionItemResponseDto extends AssetEditActionItemDto {
@ValidateUUID()
id!: string;
}
const RotateParametersSchema = z
.object({
angle: z
.number()
.refine((v) => [0, 90, 180, 270].includes(v), {
error: 'Angle must be one of the following values: 0, 90, 180, 270',
})
.describe('Rotation angle in degrees'),
})
.meta({ id: 'RotateParameters' });
const MirrorParametersSchema = z
.object({
axis: MirrorAxisSchema,
})
.meta({ id: 'MirrorParameters' });
// TODO: ideally we would use the discriminated union directly in the future not only for type support but also for validation and openapi generation
const __AssetEditActionItemSchema = z.discriminatedUnion('action', [
z.object({ action: AssetEditActionSchema.extract(['Crop']), parameters: CropParametersSchema }),
z.object({ action: AssetEditActionSchema.extract(['Rotate']), parameters: RotateParametersSchema }),
z.object({ action: AssetEditActionSchema.extract(['Mirror']), parameters: MirrorParametersSchema }),
]);
const AssetEditParametersSchema = z
.union([CropParametersSchema, RotateParametersSchema, MirrorParametersSchema], {
error: getExpectedKeysByActionMessage,
})
.describe('List of edit actions to apply (crop, rotate, or mirror)');
export type AssetEditActionParameter = typeof actionParameterMap;
const actionParameterMap = {
[AssetEditAction.Crop]: CropParameters,
[AssetEditAction.Rotate]: RotateParameters,
[AssetEditAction.Mirror]: MirrorParameters,
};
[AssetEditAction.Crop]: CropParametersSchema,
[AssetEditAction.Rotate]: RotateParametersSchema,
[AssetEditAction.Mirror]: MirrorParametersSchema,
} as const;
export class AssetEditsCreateDto {
@ArrayMinSize(1)
@IsUniqueEditActions()
@ValidateNested({ each: true })
@Type(() => AssetEditActionItemDto)
@ApiProperty({ description: 'List of edit actions to apply (crop, rotate, or mirror)' })
edits!: AssetEditActionItemDto[];
function getExpectedKeysByActionMessage(): string {
const expectedByAction = Object.entries(actionParameterMap)
.map(([action, schema]) => `${action}: [${Object.keys(schema.shape).join(', ')}]`)
.join('; ');
return `Invalid parameters for action, expected keys by action: ${expectedByAction}`;
}
export class AssetEditsResponseDto {
@ValidateUUID({ description: 'Asset ID these edits belong to' })
assetId!: string;
function isParametersValidForAction(edit: z.infer<typeof AssetEditActionItemSchema>): boolean {
return actionParameterMap[edit.action].safeParse(edit.parameters).success;
}
@ApiProperty({
description: 'List of edit actions applied to the asset',
const AssetEditActionItemSchema = z
.object({
action: AssetEditActionSchema,
parameters: AssetEditParametersSchema,
})
edits!: AssetEditActionItemResponseDto[];
.superRefine((edit, ctx) => {
if (!isParametersValidForAction(edit)) {
ctx.addIssue({
code: 'custom',
path: ['parameters'],
message: `Invalid parameters for action '${edit.action}', expecting keys: ${Object.keys(actionParameterMap[edit.action].shape).join(', ')}`,
});
}
})
.meta({ id: 'AssetEditActionItemDto' });
export type AssetEditActionItem = z.infer<typeof __AssetEditActionItemSchema>;
export type AssetEditParameters = AssetEditActionItem['parameters'];
function uniqueEditActions(edits: z.infer<typeof AssetEditActionItemSchema>[]): boolean {
const keys = new Set<string>();
for (const edit of edits) {
const key = edit.action === 'mirror' ? `mirror-${JSON.stringify(edit.parameters)}` : edit.action;
if (keys.has(key)) {
return false;
}
keys.add(key);
}
return true;
}
const AssetEditsCreateSchema = z
.object({
edits: z
.array(AssetEditActionItemSchema)
.min(1)
.describe('List of edit actions to apply (crop, rotate, or mirror)')
.refine(uniqueEditActions, { error: 'Duplicate edit actions are not allowed' }),
})
.meta({ id: 'AssetEditsCreateDto' });
const AssetEditActionItemResponseSchema = AssetEditActionItemSchema.extend({
id: z.uuidv4().describe('Asset edit ID'),
}).meta({ id: 'AssetEditActionItemResponseDto' });
const AssetEditsResponseSchema = z
.object({
assetId: z.uuidv4().describe('Asset ID these edits belong to'),
edits: z.array(AssetEditActionItemResponseSchema).describe('List of edit actions applied to the asset'),
})
.meta({ id: 'AssetEditsResponseDto' });
export class AssetEditActionItemResponseDto extends createZodDto(AssetEditActionItemResponseSchema) {}
export class AssetEditsCreateDto extends createZodDto(AssetEditsCreateSchema) {}
export class AssetEditsResponseDto extends createZodDto(AssetEditsResponseSchema) {}
export type CropParameters = z.infer<typeof CropParametersSchema>;

View File

@ -1,7 +1,6 @@
import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
import { ImmichEnvironmentSchema, LogFormatSchema, LogLevelSchema } from 'src/enum';
import { IsIPRange } from 'src/validation';
import * as z from 'zod';
// TODO import from sql-tools once the swagger plugin supports external enums
enum DatabaseSslMode {
@ -12,210 +11,79 @@ enum DatabaseSslMode {
VerifyFull = 'verify-full',
}
export class EnvDto {
@IsInt()
@Optional()
@Type(() => Number)
IMMICH_API_METRICS_PORT?: number;
const DatabaseSslModeSchema = z.enum(DatabaseSslMode).describe('Database SSL mode').meta({ id: 'DatabaseSslMode' });
const absolutePath = z.string().regex(/^\//, 'Must be an absolute path').optional();
/**
* Treat certain strings as booleans and coerce them to boolean
* Ideal for environment variables that are strings but should be treated as booleans
* @docs https://zod.dev/api?id=stringbool
*/
const stringBool = z.stringbool();
@IsString()
@Optional()
IMMICH_BUILD_DATA?: string;
@IsString()
@Optional()
IMMICH_BUILD?: string;
@IsString()
@Optional()
IMMICH_BUILD_URL?: string;
@IsString()
@Optional()
IMMICH_BUILD_IMAGE?: string;
@IsString()
@Optional()
IMMICH_BUILD_IMAGE_URL?: string;
@IsString()
@Optional()
IMMICH_CONFIG_FILE?: string;
@IsEnum(ImmichEnvironment)
@Optional()
IMMICH_ENV?: ImmichEnvironment;
@IsString()
@Optional()
IMMICH_HOST?: string;
@ValidateBoolean({ optional: true })
IMMICH_IGNORE_MOUNT_CHECK_ERRORS?: boolean;
@IsEnum(LogLevel)
@Optional()
IMMICH_LOG_LEVEL?: LogLevel;
@IsEnum(LogFormat)
@Optional()
IMMICH_LOG_FORMAT?: LogFormat;
@Optional()
@Matches(/^\//, { message: 'IMMICH_MEDIA_LOCATION must be an absolute path' })
IMMICH_MEDIA_LOCATION?: string;
@IsInt()
@Optional()
@Type(() => Number)
IMMICH_MICROSERVICES_METRICS_PORT?: number;
@ValidateBoolean({ optional: true })
IMMICH_ALLOW_EXTERNAL_PLUGINS?: boolean;
@Optional()
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
IMMICH_PLUGINS_INSTALL_FOLDER?: string;
@IsInt()
@Optional()
@Type(() => Number)
IMMICH_PORT?: number;
@IsString()
@Optional()
IMMICH_REPOSITORY?: string;
@IsString()
@Optional()
IMMICH_REPOSITORY_URL?: string;
@IsString()
@Optional()
IMMICH_SOURCE_REF?: string;
@IsString()
@Optional()
IMMICH_SOURCE_COMMIT?: string;
@IsString()
@Optional()
IMMICH_SOURCE_URL?: string;
@IsString()
@Optional()
IMMICH_TELEMETRY_INCLUDE?: string;
@IsString()
@Optional()
IMMICH_TELEMETRY_EXCLUDE?: string;
@IsString()
@Optional()
IMMICH_THIRD_PARTY_SOURCE_URL?: string;
@IsString()
@Optional()
IMMICH_THIRD_PARTY_BUG_FEATURE_URL?: string;
@IsString()
@Optional()
IMMICH_THIRD_PARTY_DOCUMENTATION_URL?: string;
@IsString()
@Optional()
IMMICH_THIRD_PARTY_SUPPORT_URL?: string;
@ValidateBoolean({ optional: true })
IMMICH_ALLOW_SETUP?: boolean;
@IsIPRange({ requireCIDR: false }, { each: true })
@Transform(({ value }) =>
value && typeof value === 'string'
? value
const trustedProxiesSchema = z
.string()
.optional()
.transform((s) =>
s
? s
.split(',')
.map((value) => value.trim())
.map((x) => x.trim())
.filter(Boolean)
: value,
: undefined,
)
@Optional()
IMMICH_TRUSTED_PROXIES?: string[];
@IsString()
@Optional()
IMMICH_WORKERS_INCLUDE?: string;
.pipe(z.union([z.undefined(), IsIPRange({ requireCIDR: false })]));
@IsString()
@Optional()
IMMICH_WORKERS_EXCLUDE?: string;
@IsString()
@Optional()
DB_DATABASE_NAME?: string;
@IsString()
@Optional()
DB_HOSTNAME?: string;
@IsString()
@Optional()
DB_PASSWORD?: string;
@IsInt()
@Optional()
@Type(() => Number)
DB_PORT?: number;
@ValidateBoolean({ optional: true })
DB_SKIP_MIGRATIONS?: boolean;
@IsEnum(DatabaseSslMode)
@Optional()
DB_SSL_MODE?: DatabaseSslMode;
@IsString()
@Optional()
DB_URL?: string;
@IsString()
@Optional()
DB_USERNAME?: string;
@IsEnum(['pgvector', 'pgvecto.rs', 'vectorchord'])
@Optional()
DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs' | 'vectorchord';
@IsString()
@Optional()
NO_COLOR?: string;
@IsString()
@Optional()
REDIS_HOSTNAME?: string;
@IsInt()
@Optional()
@Type(() => Number)
REDIS_PORT?: number;
@IsInt()
@Optional()
@Type(() => Number)
REDIS_DBINDEX?: number;
@IsString()
@Optional()
REDIS_USERNAME?: string;
@IsString()
@Optional()
REDIS_PASSWORD?: string;
@IsString()
@Optional()
REDIS_SOCKET?: string;
@IsString()
@Optional()
REDIS_URL?: string;
}
export const EnvSchema = z
.object({
IMMICH_API_METRICS_PORT: z.coerce.number().int().optional(),
IMMICH_BUILD_DATA: z.string().optional(),
IMMICH_BUILD: z.string().optional(),
IMMICH_BUILD_URL: z.string().optional(),
IMMICH_BUILD_IMAGE: z.string().optional(),
IMMICH_BUILD_IMAGE_URL: z.string().optional(),
IMMICH_CONFIG_FILE: z.string().optional(),
IMMICH_ENV: ImmichEnvironmentSchema.optional(),
IMMICH_HOST: z.string().optional(),
IMMICH_IGNORE_MOUNT_CHECK_ERRORS: stringBool.optional(),
IMMICH_LOG_LEVEL: LogLevelSchema.optional(),
IMMICH_LOG_FORMAT: LogFormatSchema.optional(),
IMMICH_MEDIA_LOCATION: absolutePath,
IMMICH_MICROSERVICES_METRICS_PORT: z.coerce.number().int().optional(),
IMMICH_ALLOW_EXTERNAL_PLUGINS: stringBool.optional(),
IMMICH_PLUGINS_INSTALL_FOLDER: absolutePath,
IMMICH_PORT: z.coerce.number().int().optional(),
IMMICH_REPOSITORY: z.string().optional(),
IMMICH_REPOSITORY_URL: z.string().optional(),
IMMICH_SOURCE_REF: z.string().optional(),
IMMICH_SOURCE_COMMIT: z.string().optional(),
IMMICH_SOURCE_URL: z.string().optional(),
IMMICH_TELEMETRY_INCLUDE: z.string().optional(),
IMMICH_TELEMETRY_EXCLUDE: z.string().optional(),
IMMICH_THIRD_PARTY_SOURCE_URL: z.string().optional(),
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: z.string().optional(),
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: z.string().optional(),
IMMICH_THIRD_PARTY_SUPPORT_URL: z.string().optional(),
IMMICH_ALLOW_SETUP: stringBool.optional(),
IMMICH_TRUSTED_PROXIES: trustedProxiesSchema,
IMMICH_WORKERS_INCLUDE: z.string().optional(),
IMMICH_WORKERS_EXCLUDE: z.string().optional(),
DB_DATABASE_NAME: z.string().optional(),
DB_HOSTNAME: z.string().optional(),
DB_PASSWORD: z.string().optional(),
DB_PORT: z.coerce.number().int().optional(),
DB_SKIP_MIGRATIONS: stringBool.optional(),
DB_SSL_MODE: DatabaseSslModeSchema.optional(),
DB_URL: z.string().optional(),
DB_USERNAME: z.string().optional(),
DB_VECTOR_EXTENSION: z.enum(['pgvector', 'pgvecto.rs', 'vectorchord']).optional(),
NO_COLOR: z.string().optional(),
REDIS_HOSTNAME: z.string().optional(),
REDIS_PORT: z.coerce.number().int().optional(),
REDIS_DBINDEX: z.coerce.number().int().optional(),
REDIS_USERNAME: z.string().optional(),
REDIS_PASSWORD: z.string().optional(),
REDIS_SOCKET: z.string().optional(),
REDIS_URL: z.string().optional(),
})
.meta({ id: 'EnvDto' });

View File

@ -1,55 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { createZodDto } from 'nestjs-zod';
import { Exif } from 'src/database';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import * as z from 'zod';
export class ExifResponseDto {
@ApiPropertyOptional({ description: 'Camera make' })
make?: string | null = null;
@ApiPropertyOptional({ description: 'Camera model' })
model?: string | null = null;
@ApiPropertyOptional({ type: 'number', description: 'Image width in pixels' })
exifImageWidth?: number | null = null;
@ApiPropertyOptional({ type: 'number', description: 'Image height in pixels' })
exifImageHeight?: number | null = null;
export const ExifResponseSchema = z
.object({
make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
dateTimeOriginal: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Original date/time'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
modifyDate: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Modification date/time'),
timeZone: z.string().nullish().default(null).describe('Time zone'),
lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.number().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'),
city: z.string().nullish().default(null).describe('City name'),
state: z.string().nullish().default(null).describe('State/province name'),
country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.number().nullish().default(null).describe('Rating'),
})
.describe('EXIF response')
.meta({ id: 'ExifResponseDto' });
@ApiProperty({ type: 'integer', format: 'int64', description: 'File size in bytes' })
fileSizeInByte?: number | null = null;
@ApiPropertyOptional({ description: 'Image orientation' })
orientation?: string | null = null;
@ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' })
dateTimeOriginal?: string | null = null;
@ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' })
modifyDate?: string | null = null;
@ApiPropertyOptional({ description: 'Time zone' })
timeZone?: string | null = null;
@ApiPropertyOptional({ description: 'Lens model' })
lensModel?: string | null = null;
@ApiPropertyOptional({ type: 'number', description: 'F-number (aperture)' })
fNumber?: number | null = null;
@ApiPropertyOptional({ type: 'number', description: 'Focal length in mm' })
focalLength?: number | null = null;
@ApiPropertyOptional({ type: 'number', description: 'ISO sensitivity' })
iso?: number | null = null;
@ApiPropertyOptional({ description: 'Exposure time' })
exposureTime?: string | null = null;
@ApiPropertyOptional({ type: 'number', description: 'GPS latitude' })
latitude?: number | null = null;
@ApiPropertyOptional({ type: 'number', description: 'GPS longitude' })
longitude?: number | null = null;
@ApiPropertyOptional({ description: 'City name' })
city?: string | null = null;
@ApiPropertyOptional({ description: 'State/province name' })
state?: string | null = null;
@ApiPropertyOptional({ description: 'Country name' })
country?: string | null = null;
@ApiPropertyOptional({ description: 'Image description' })
description?: string | null = null;
@ApiPropertyOptional({ description: 'Projection type' })
projectionType?: string | null = null;
@ApiPropertyOptional({ type: 'number', description: 'Rating' })
rating?: number | null = null;
}
class ExifResponseDto extends createZodDto(ExifResponseSchema) {}
export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
return {
@ -77,16 +62,3 @@ export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
rating: entity.rating,
};
}
export function mapSanitizedExif(entity: Exif): ExifResponseDto {
return {
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation,
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
timeZone: entity.timeZone,
projectionType: entity.projectionType,
exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight,
rating: entity.rating,
};
}

View File

@ -1,7 +1,11 @@
import { ManualJobName } from 'src/enum';
import { ValidateEnum } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { ManualJobNameSchema } from 'src/enum';
import * as z from 'zod';
export class JobCreateDto {
@ValidateEnum({ enum: ManualJobName, name: 'ManualJobName', description: 'Job name' })
name!: ManualJobName;
}
const JobCreateSchema = z
.object({
name: ManualJobNameSchema,
})
.meta({ id: 'JobCreateDto' });
export class JobCreateDto extends createZodDto(JobCreateSchema) {}

View File

@ -1,58 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Library } from 'src/database';
import { Optional, ValidateUUID } from 'src/validation';
import { isoDatetimeToDate } from 'src/validation';
import * as z from 'zod';
export class CreateLibraryDto {
@ValidateUUID({ description: 'Owner user ID' })
ownerId!: string;
const stringArrayMax128 = z
.array(z.string())
.max(128)
.refine((arr) => arr.every((s) => s.trim() !== ''), 'Array items must not be empty')
.refine((arr) => new Set(arr).size === arr.length, 'Array must have unique items');
@ApiPropertyOptional({ description: 'Library name' })
@IsString()
@Optional()
@IsNotEmpty()
name?: string;
const CreateLibrarySchema = z
.object({
ownerId: z.uuidv4().describe('Owner user ID'),
name: z.string().min(1).optional().describe('Library name'),
importPaths: stringArrayMax128.optional().describe('Import paths (max 128)'),
exclusionPatterns: stringArrayMax128.optional().describe('Exclusion patterns (max 128)'),
})
.meta({ id: 'CreateLibraryDto' });
@ApiPropertyOptional({ description: 'Import paths (max 128)' })
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
importPaths?: string[];
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
exclusionPatterns?: string[];
}
export class UpdateLibraryDto {
@ApiPropertyOptional({ description: 'Library name' })
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@ApiPropertyOptional({ description: 'Import paths (max 128)' })
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
importPaths?: string[];
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
@Optional()
@IsNotEmpty({ each: true })
@IsString({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
exclusionPatterns?: string[];
}
const UpdateLibrarySchema = z
.object({
name: z.string().min(1).optional().describe('Library name'),
importPaths: stringArrayMax128.optional().describe('Import paths (max 128)'),
exclusionPatterns: stringArrayMax128.optional().describe('Exclusion patterns (max 128)'),
})
.meta({ id: 'UpdateLibraryDto' });
export interface CrawlOptionsDto {
pathsToCrawl: string[];
@ -64,81 +36,60 @@ export interface WalkOptionsDto extends CrawlOptionsDto {
take: number;
}
export class ValidateLibraryDto {
@ApiPropertyOptional({ description: 'Import paths to validate (max 128)' })
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
importPaths?: string[];
const ValidateLibrarySchema = z
.object({
importPaths: stringArrayMax128.optional().describe('Import paths to validate (max 128)'),
exclusionPatterns: stringArrayMax128.optional().describe('Exclusion patterns (max 128)'),
})
.meta({ id: 'ValidateLibraryDto' });
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
@Optional()
@IsNotEmpty({ each: true })
@IsString({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
exclusionPatterns?: string[];
}
const ValidateLibraryImportPathResponseSchema = z
.object({
importPath: z.string().describe('Import path'),
isValid: z.boolean().describe('Is valid'),
message: z.string().optional().describe('Validation message'),
})
.meta({ id: 'ValidateLibraryImportPathResponseDto' });
export class ValidateLibraryResponseDto {
@ApiPropertyOptional({ description: 'Validation results for import paths' })
importPaths?: ValidateLibraryImportPathResponseDto[];
}
const ValidateLibraryResponseSchema = z
.object({
importPaths: z
.array(ValidateLibraryImportPathResponseSchema)
.optional()
.describe('Validation results for import paths'),
})
.meta({ id: 'ValidateLibraryResponseDto' });
export class ValidateLibraryImportPathResponseDto {
@ApiProperty({ description: 'Import path' })
importPath!: string;
@ApiProperty({ description: 'Is valid' })
isValid: boolean = false;
@ApiPropertyOptional({ description: 'Validation message' })
message?: string;
}
const LibraryResponseSchema = z
.object({
id: z.string().describe('Library ID'),
ownerId: z.string().describe('Owner user ID'),
name: z.string().describe('Library name'),
assetCount: z.int().describe('Number of assets'),
importPaths: z.array(z.string()).describe('Import paths'),
exclusionPatterns: z.array(z.string()).describe('Exclusion patterns'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
refreshedAt: isoDatetimeToDate.nullable().describe('Last refresh date'),
})
.meta({ id: 'LibraryResponseDto' });
export class LibrarySearchDto {
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
userId?: string;
}
const LibraryStatsResponseSchema = z
.object({
photos: z.int().describe('Number of photos'),
videos: z.int().describe('Number of videos'),
total: z.int().describe('Total number of assets'),
usage: z.int().describe('Storage usage in bytes'),
})
.meta({ id: 'LibraryStatsResponseDto' });
export class LibraryResponseDto {
@ApiProperty({ description: 'Library ID' })
id!: string;
@ApiProperty({ description: 'Owner user ID' })
ownerId!: string;
@ApiProperty({ description: 'Library name' })
name!: string;
@ApiProperty({ type: 'integer', description: 'Number of assets' })
assetCount!: number;
@ApiProperty({ description: 'Import paths' })
importPaths!: string[];
@ApiProperty({ description: 'Exclusion patterns' })
exclusionPatterns!: string[];
@ApiProperty({ description: 'Creation date' })
createdAt!: Date;
@ApiProperty({ description: 'Last update date' })
updatedAt!: Date;
@ApiProperty({ description: 'Last refresh date' })
refreshedAt!: Date | null;
}
export class LibraryStatsResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of photos' })
photos = 0;
@ApiProperty({ type: 'integer', description: 'Number of videos' })
videos = 0;
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
total = 0;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
usage = 0;
}
export class CreateLibraryDto extends createZodDto(CreateLibrarySchema) {}
export class UpdateLibraryDto extends createZodDto(UpdateLibrarySchema) {}
export class ValidateLibraryDto extends createZodDto(ValidateLibrarySchema) {}
export class ValidateLibraryResponseDto extends createZodDto(ValidateLibraryResponseSchema) {}
export class ValidateLibraryImportPathResponseDto extends createZodDto(ValidateLibraryImportPathResponseSchema) {}
export class LibraryResponseDto extends createZodDto(LibraryResponseSchema) {}
export class LibraryStatsResponseDto extends createZodDto(LibraryStatsResponseSchema) {}
export function mapLibrary(entity: Library): LibraryResponseDto {
let assetCount = 0;

View File

@ -1,20 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { UserLicenseSchema } from 'src/dtos/user.dto';
export class LicenseKeyDto {
@ApiProperty({ description: 'License key (format: IM(SV|CL)(-XXXX){8})' })
@IsString()
@IsNotEmpty()
@Matches(/IM(SV|CL)(-[\dA-Za-z]{4}){8}/)
licenseKey!: string;
const LicenseKeySchema = UserLicenseSchema.pick({
licenseKey: true,
activationKey: true,
}).meta({ id: 'LicenseKeyDto' });
@ApiProperty({ description: 'Activation key' })
@IsString()
@IsNotEmpty()
activationKey!: string;
}
const LicenseResponseSchema = UserLicenseSchema.meta({ id: 'LicenseResponseDto' });
export class LicenseResponseDto extends LicenseKeyDto {
@ApiProperty({ description: 'Activation date' })
activatedAt!: Date;
}
export class LicenseKeyDto extends createZodDto(LicenseKeySchema) {}
export class LicenseResponseDto extends createZodDto(LicenseResponseSchema) {}

View File

@ -1,49 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import { ValidateIf } from 'class-validator';
import { MaintenanceAction, StorageFolder } from 'src/enum';
import { ValidateBoolean, ValidateEnum, ValidateString } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { MaintenanceAction, MaintenanceActionSchema, StorageFolderSchema } from 'src/enum';
import * as z from 'zod';
export class SetMaintenanceModeDto {
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction', description: 'Maintenance action' })
action!: MaintenanceAction;
const SetMaintenanceModeSchema = z
.object({
action: MaintenanceActionSchema,
restoreBackupFilename: z.string().optional().describe('Restore backup filename'),
})
.refine(
(data) => data.action !== MaintenanceAction.RestoreDatabase || (data.restoreBackupFilename?.length ?? 0) > 0,
{ error: 'Backup filename is required when action is restore_database', path: ['restoreBackupFilename'] },
)
.meta({ id: 'SetMaintenanceModeDto' });
@ValidateIf((o) => o.action === MaintenanceAction.RestoreDatabase)
@ValidateString({ description: 'Restore backup filename' })
restoreBackupFilename?: string;
}
const MaintenanceLoginSchema = z
.object({
token: z.string().optional().describe('Maintenance token'),
})
.meta({ id: 'MaintenanceLoginDto' });
export class MaintenanceLoginDto {
@ValidateString({ optional: true, description: 'Maintenance token' })
token?: string;
}
const MaintenanceAuthSchema = z
.object({
username: z.string().describe('Maintenance username'),
})
.meta({ id: 'MaintenanceAuthDto' });
export class MaintenanceAuthDto {
@ApiProperty({ description: 'Maintenance username' })
username!: string;
}
const MaintenanceStatusResponseSchema = z
.object({
active: z.boolean(),
action: MaintenanceActionSchema,
progress: z.number().optional(),
task: z.string().optional(),
error: z.string().optional(),
})
.meta({ id: 'MaintenanceStatusResponseDto' });
export class MaintenanceStatusResponseDto {
active!: boolean;
const MaintenanceDetectInstallStorageFolderSchema = z
.object({
folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'),
files: z.number().describe('Number of files in the folder'),
})
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction', description: 'Maintenance action' })
action!: MaintenanceAction;
const MaintenanceDetectInstallResponseSchema = z
.object({
storage: z.array(MaintenanceDetectInstallStorageFolderSchema),
})
.meta({ id: 'MaintenanceDetectInstallResponseDto' });
progress?: number;
task?: string;
error?: string;
}
export class MaintenanceDetectInstallStorageFolderDto {
@ValidateEnum({ enum: StorageFolder, name: 'StorageFolder', description: 'Storage folder' })
folder!: StorageFolder;
@ValidateBoolean({ description: 'Whether the folder is readable' })
readable!: boolean;
@ValidateBoolean({ description: 'Whether the folder is writable' })
writable!: boolean;
@ApiProperty({ description: 'Number of files in the folder' })
files!: number;
}
export class MaintenanceDetectInstallResponseDto {
storage!: MaintenanceDetectInstallStorageFolderDto[];
}
export class SetMaintenanceModeDto extends createZodDto(SetMaintenanceModeSchema) {}
export class MaintenanceLoginDto extends createZodDto(MaintenanceLoginSchema) {}
export class MaintenanceAuthDto extends createZodDto(MaintenanceAuthSchema) {}
export class MaintenanceStatusResponseDto extends createZodDto(MaintenanceStatusResponseSchema) {}
export class MaintenanceDetectInstallResponseDto extends createZodDto(MaintenanceDetectInstallResponseSchema) {}

View File

@ -1,67 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsLatitude, IsLongitude } from 'class-validator';
import { ValidateBoolean, ValidateDate } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { isoDatetimeToDate, latitudeSchema, longitudeSchema, stringToBool } from 'src/validation';
import * as z from 'zod';
export class MapReverseGeocodeDto {
@ApiProperty({ format: 'double', description: 'Latitude (-90 to 90)' })
@Type(() => Number)
@IsLatitude({ message: ({ property }) => `${property} must be a number between -90 and 90` })
lat!: number;
const MapReverseGeocodeSchema = z
.object({
lat: z.coerce.number().meta({ format: 'double' }).pipe(latitudeSchema).describe('Latitude (-90 to 90)'),
lon: z.coerce.number().meta({ format: 'double' }).pipe(longitudeSchema).describe('Longitude (-180 to 180)'),
})
.meta({ id: 'MapReverseGeocodeDto' });
@ApiProperty({ format: 'double', description: 'Longitude (-180 to 180)' })
@Type(() => Number)
@IsLongitude({ message: ({ property }) => `${property} must be a number between -180 and 180` })
lon!: number;
}
const MapReverseGeocodeResponseSchema = z
.object({
city: z.string().nullable().describe('City name'),
state: z.string().nullable().describe('State/Province name'),
country: z.string().nullable().describe('Country name'),
})
.meta({ id: 'MapReverseGeocodeResponseDto' });
export class MapReverseGeocodeResponseDto {
@ApiProperty({ description: 'City name' })
city!: string | null;
const MapMarkerSchema = z
.object({
isArchived: stringToBool.optional().describe('Filter by archived status'),
isFavorite: stringToBool.optional().describe('Filter by favorite status'),
fileCreatedAfter: isoDatetimeToDate.optional().describe('Filter assets created after this date'),
fileCreatedBefore: isoDatetimeToDate.optional().describe('Filter assets created before this date'),
withPartners: stringToBool.optional().describe('Include partner assets'),
withSharedAlbums: stringToBool.optional().describe('Include shared album assets'),
})
.meta({ id: 'MapMarkerDto' });
@ApiProperty({ description: 'State/Province name' })
state!: string | null;
const MapMarkerResponseSchema = z
.object({
id: z.string().describe('Asset ID'),
lat: z.number().meta({ format: 'double' }).describe('Latitude'),
lon: z.number().meta({ format: 'double' }).describe('Longitude'),
city: z.string().nullable().describe('City name'),
state: z.string().nullable().describe('State/Province name'),
country: z.string().nullable().describe('Country name'),
})
.meta({ id: 'MapMarkerResponseDto' });
@ApiProperty({ description: 'Country name' })
country!: string | null;
}
export class MapMarkerDto {
@ValidateBoolean({ optional: true, description: 'Filter by archived status' })
isArchived?: boolean;
@ValidateBoolean({ optional: true, description: 'Filter by favorite status' })
isFavorite?: boolean;
@ValidateDate({ optional: true, description: 'Filter assets created after this date' })
fileCreatedAfter?: Date;
@ValidateDate({ optional: true, description: 'Filter assets created before this date' })
fileCreatedBefore?: Date;
@ValidateBoolean({ optional: true, description: 'Include partner assets' })
withPartners?: boolean;
@ValidateBoolean({ optional: true, description: 'Include shared album assets' })
withSharedAlbums?: boolean;
}
export class MapMarkerResponseDto {
@ApiProperty({ description: 'Asset ID' })
id!: string;
@ApiProperty({ format: 'double', description: 'Latitude' })
lat!: number;
@ApiProperty({ format: 'double', description: 'Longitude' })
lon!: number;
@ApiProperty({ description: 'City name' })
city!: string | null;
@ApiProperty({ description: 'State/Province name' })
state!: string | null;
@ApiProperty({ description: 'Country name' })
country!: string | null;
}
export class MapReverseGeocodeDto extends createZodDto(MapReverseGeocodeSchema) {}
export class MapReverseGeocodeResponseDto extends createZodDto(MapReverseGeocodeResponseSchema) {}
export class MapMarkerDto extends createZodDto(MapMarkerSchema) {}
export class MapMarkerResponseDto extends createZodDto(MapMarkerResponseSchema) {}

View File

@ -1,136 +1,94 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Memory } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandom, MemoryType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
import { AssetOrderWithRandomSchema, MemoryType, MemoryTypeSchema } from 'src/enum';
import { isoDatetimeToDate, stringToBool } from 'src/validation';
import * as z from 'zod';
class MemoryBaseDto {
@ValidateBoolean({ optional: true, description: 'Is memory saved' })
isSaved?: boolean;
@ValidateDate({ optional: true, description: 'Date when memory was seen' })
seenAt?: Date;
}
export class MemorySearchDto {
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type', optional: true })
type?: MemoryType;
@ValidateDate({ optional: true, description: 'Filter by date' })
for?: Date;
@ValidateBoolean({ optional: true, description: 'Include trashed memories' })
isTrashed?: boolean;
@ValidateBoolean({ optional: true, description: 'Filter by saved status' })
isSaved?: boolean;
@IsInt()
@IsPositive()
@Type(() => Number)
@Optional()
@ApiProperty({ type: 'integer', description: 'Number of memories to return' })
size?: number;
@ValidateEnum({ enum: AssetOrderWithRandom, name: 'MemorySearchOrder', description: 'Sort order', optional: true })
order?: AssetOrderWithRandom;
}
class OnThisDayDto {
@ApiProperty({ type: 'number', description: 'Year for on this day memory', minimum: 1 })
@IsInt()
@IsPositive()
year!: number;
}
type MemoryData = OnThisDayDto;
export class MemoryUpdateDto extends MemoryBaseDto {
@ValidateDate({ optional: true, description: 'Memory date' })
memoryAt?: Date;
}
export class MemoryCreateDto extends MemoryBaseDto {
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type' })
type!: MemoryType;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
@IsObject()
@ValidateNested()
@Type((options) => {
switch (options?.object.type) {
case MemoryType.OnThisDay: {
return OnThisDayDto;
}
default: {
return Object;
}
}
const MemorySearchSchema = z
.object({
type: MemoryTypeSchema.optional(),
for: isoDatetimeToDate.optional().describe('Filter by date'),
isTrashed: stringToBool.optional().describe('Include trashed memories'),
isSaved: stringToBool.optional().describe('Filter by saved status'),
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
order: AssetOrderWithRandomSchema.optional(),
})
data!: MemoryData;
.meta({ id: 'MemorySearchDto' });
@ValidateDate({ description: 'Memory date' })
memoryAt!: Date;
@ValidateDate({
optional: true,
description: 'Date when memory should be shown',
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
const OnThisDaySchema = z
.object({
year: z
.number()
.min(1)
.max(9999)
.refine((val) => /^\d{4}$/.test(String(val)), {
error: 'Year must be exactly 4 digits',
})
.describe('Year for on this day memory'),
})
showAt?: Date;
.meta({ id: 'OnThisDayDto' });
@ValidateDate({
optional: true,
description: 'Date when memory should be hidden',
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
type MemoryData = z.infer<typeof OnThisDaySchema>;
const MemoryUpdateSchema = z
.object({
isSaved: z.boolean().optional().describe('Is memory saved'),
seenAt: isoDatetimeToDate.optional().describe('Date when memory was seen'),
memoryAt: isoDatetimeToDate.optional().describe('Memory date'),
})
hideAt?: Date;
.meta({ id: 'MemoryUpdateDto' });
@ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' })
assetIds?: string[];
}
const MemoryCreateSchema = z
.object({
type: MemoryTypeSchema,
data: OnThisDaySchema,
memoryAt: isoDatetimeToDate.describe('Memory date'),
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs to associate with memory'),
isSaved: z.boolean().optional().describe('Is memory saved'),
seenAt: isoDatetimeToDate.optional().describe('Date when memory was seen'),
showAt: isoDatetimeToDate
.optional()
.describe('Date when memory should be shown')
.meta({ ...new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions() }),
hideAt: isoDatetimeToDate
.optional()
.describe('Date when memory should be hidden')
.meta({ ...new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions() }),
})
.meta({ id: 'MemoryCreateDto' });
export class MemoryStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of memories' })
total!: number;
}
const MemoryStatisticsResponseSchema = z
.object({
total: z.int().describe('Total number of memories'),
})
.meta({ id: 'MemoryStatisticsResponseDto' });
export class MemoryResponseDto {
@ApiProperty({ description: 'Memory ID' })
id!: string;
@ValidateDate({ description: 'Creation date' })
createdAt!: Date;
@ValidateDate({ description: 'Last update date' })
updatedAt!: Date;
@ValidateDate({ optional: true, description: 'Deletion date' })
deletedAt?: Date;
@ValidateDate({ description: 'Memory date' })
memoryAt!: Date;
@ValidateDate({ optional: true, description: 'Date when memory was seen' })
seenAt?: Date;
@ValidateDate({ optional: true, description: 'Date when memory should be shown' })
showAt?: Date;
@ValidateDate({ optional: true, description: 'Date when memory should be hidden' })
hideAt?: Date;
@ApiProperty({ description: 'Owner user ID' })
ownerId!: string;
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type' })
type!: MemoryType;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
data!: MemoryData;
@ApiProperty({ description: 'Is memory saved' })
isSaved!: boolean;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
assets!: AssetResponseDto[];
}
const MemoryResponseSchema = z
.object({
id: z.string().describe('Memory ID'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
deletedAt: isoDatetimeToDate.optional().describe('Deletion date'),
memoryAt: isoDatetimeToDate.describe('Memory date'),
seenAt: isoDatetimeToDate.optional().describe('Date when memory was seen'),
showAt: isoDatetimeToDate.optional().describe('Date when memory should be shown'),
hideAt: isoDatetimeToDate.optional().describe('Date when memory should be hidden'),
ownerId: z.string().describe('Owner user ID'),
type: MemoryTypeSchema,
data: OnThisDaySchema,
isSaved: z.boolean().describe('Is memory saved'),
assets: z.array(AssetResponseSchema),
})
.meta({ id: 'MemoryResponseDto' });
export class MemorySearchDto extends createZodDto(MemorySearchSchema) {}
export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {}
export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {}
export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {}
export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {}
export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
return {

View File

@ -1,83 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator';
import { ValidateBoolean } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export class TaskConfig {
@ValidateBoolean({ description: 'Whether the task is enabled' })
enabled!: boolean;
}
export class ModelConfig extends TaskConfig {
@ApiProperty({ description: 'Name of the model to use' })
@IsString()
@IsNotEmpty()
modelName!: string;
}
export class CLIPConfig extends ModelConfig {}
export class DuplicateDetectionConfig extends TaskConfig {
@IsNumber()
@Min(0.001)
@Max(0.1)
@Type(() => Number)
@ApiProperty({
type: 'number',
format: 'double',
description: 'Maximum distance threshold for duplicate detection',
const TaskConfigSchema = z
.object({
enabled: z.boolean().describe('Whether the task is enabled'),
})
maxDistance!: number;
}
.meta({ id: 'TaskConfig' });
export class FacialRecognitionConfig extends ModelConfig {
@IsNumber()
@Min(0.1)
@Max(1)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double', description: 'Minimum confidence score for face detection' })
minScore!: number;
const ModelConfigSchema = TaskConfigSchema.extend({
modelName: z.string().describe('Name of the model to use'),
});
@IsNumber()
@Min(0.1)
@Max(2)
@Type(() => Number)
@ApiProperty({
type: 'number',
format: 'double',
description: 'Maximum distance threshold for face recognition',
})
maxDistance!: number;
export const CLIPConfigSchema = ModelConfigSchema.meta({ id: 'CLIPConfig' });
@IsNumber()
@Min(1)
@Type(() => Number)
@ApiProperty({ type: 'integer', description: 'Minimum number of faces required for recognition' })
minFaces!: number;
}
export const DuplicateDetectionConfigSchema = TaskConfigSchema.extend({
maxDistance: z
.number()
.meta({ format: 'double' })
.min(0.001)
.max(0.1)
.describe('Maximum distance threshold for duplicate detection'),
}).meta({ id: 'DuplicateDetectionConfig' });
export class OcrConfig extends ModelConfig {
@IsNumber()
@Min(1)
@Type(() => Number)
@ApiProperty({ type: 'integer', description: 'Maximum resolution for OCR processing' })
maxResolution!: number;
export const FacialRecognitionConfigSchema = ModelConfigSchema.extend({
minScore: z
.number()
.meta({ format: 'double' })
.min(0.1)
.max(1)
.describe('Minimum confidence score for face detection'),
maxDistance: z
.number()
.meta({ format: 'double' })
.min(0.1)
.max(2)
.describe('Maximum distance threshold for face recognition'),
minFaces: z.int().min(1).describe('Minimum number of faces required for recognition'),
}).meta({ id: 'FacialRecognitionConfig' });
@IsNumber()
@Min(0.1)
@Max(1)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double', description: 'Minimum confidence score for text detection' })
minDetectionScore!: number;
export const OcrConfigSchema = ModelConfigSchema.extend({
maxResolution: z.int().min(1).describe('Maximum resolution for OCR processing'),
minDetectionScore: z
.number()
.meta({ format: 'double' })
.min(0.1)
.max(1)
.describe('Minimum confidence score for text detection'),
minRecognitionScore: z
.number()
.meta({ format: 'double' })
.min(0.1)
.max(1)
.describe('Minimum confidence score for text recognition'),
}).meta({ id: 'OcrConfig' });
@IsNumber()
@Min(0.1)
@Max(1)
@Type(() => Number)
@ApiProperty({
type: 'number',
format: 'double',
description: 'Minimum confidence score for text recognition',
})
minRecognitionScore!: number;
}
export class CLIPConfig extends createZodDto(CLIPConfigSchema) {}

View File

@ -1,118 +1,91 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsString } from 'class-validator';
import { NotificationLevel, NotificationType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { NotificationLevel, NotificationLevelSchema, NotificationType, NotificationTypeSchema } from 'src/enum';
import { isoDatetimeToDate, stringToBool } from 'src/validation';
import * as z from 'zod';
export class TestEmailResponseDto {
@ApiProperty({ description: 'Email message ID' })
messageId!: string;
}
export class TemplateResponseDto {
@ApiProperty({ description: 'Template name' })
name!: string;
@ApiProperty({ description: 'Template HTML content' })
html!: string;
}
export class TemplateDto {
@ApiProperty({ description: 'Template name' })
@IsString()
template!: string;
}
export class NotificationDto {
@ApiProperty({ description: 'Notification ID' })
id!: string;
@ValidateDate({ description: 'Creation date' })
createdAt!: Date;
@ValidateEnum({ enum: NotificationLevel, name: 'NotificationLevel', description: 'Notification level' })
level!: NotificationLevel;
@ValidateEnum({ enum: NotificationType, name: 'NotificationType', description: 'Notification type' })
type!: NotificationType;
@ApiProperty({ description: 'Notification title' })
title!: string;
@ApiPropertyOptional({ description: 'Notification description' })
description?: string;
@ApiPropertyOptional({ description: 'Additional notification data' })
data?: any;
@ApiPropertyOptional({ description: 'Date when notification was read', format: 'date-time' })
readAt?: Date;
}
export class NotificationSearchDto {
@ValidateUUID({ optional: true, description: 'Filter by notification ID' })
id?: string;
@ValidateEnum({
enum: NotificationLevel,
name: 'NotificationLevel',
optional: true,
description: 'Filter by notification level',
const TestEmailResponseSchema = z
.object({
messageId: z.string().describe('Email message ID'),
})
level?: NotificationLevel;
.meta({ id: 'TestEmailResponseDto' });
@ValidateEnum({
enum: NotificationType,
name: 'NotificationType',
optional: true,
description: 'Filter by notification type',
const TemplateResponseSchema = z
.object({
name: z.string().describe('Template name'),
html: z.string().describe('Template HTML content'),
})
type?: NotificationType;
.meta({ id: 'TemplateResponseDto' });
@ValidateBoolean({ optional: true, description: 'Filter by unread status' })
unread?: boolean;
}
export class NotificationCreateDto {
@ValidateEnum({
enum: NotificationLevel,
name: 'NotificationLevel',
optional: true,
description: 'Notification level',
const TemplateSchema = z
.object({
template: z.string().describe('Template name'),
})
level?: NotificationLevel;
.meta({ id: 'TemplateDto' });
@ValidateEnum({ enum: NotificationType, name: 'NotificationType', optional: true, description: 'Notification type' })
type?: NotificationType;
const NotificationSchema = z
.object({
id: z.string().describe('Notification ID'),
createdAt: isoDatetimeToDate.describe('Creation date'),
level: NotificationLevelSchema,
type: NotificationTypeSchema,
title: z.string().describe('Notification title'),
description: z.string().optional().describe('Notification description'),
data: z.record(z.string(), z.unknown()).optional().describe('Additional notification data'),
readAt: isoDatetimeToDate.optional().describe('Date when notification was read'),
})
.meta({ id: 'NotificationDto' });
@ValidateString({ description: 'Notification title' })
title!: string;
const NotificationSearchSchema = z
.object({
id: z.uuidv4().optional().describe('Filter by notification ID'),
level: NotificationLevelSchema.optional(),
type: NotificationTypeSchema.optional(),
unread: stringToBool.optional().describe('Filter by unread status'),
})
.meta({ id: 'NotificationSearchDto' });
@ValidateString({ optional: true, nullable: true, description: 'Notification description' })
description?: string | null;
const NotificationCreateSchema = z
.object({
level: NotificationLevelSchema.optional(),
type: NotificationTypeSchema.optional(),
title: z.string().describe('Notification title'),
description: z.string().nullish().describe('Notification description'),
data: z.record(z.string(), z.unknown()).optional().describe('Additional notification data'),
readAt: isoDatetimeToDate.nullish().describe('Date when notification was read'),
userId: z.uuidv4().describe('User ID to send notification to'),
})
.meta({ id: 'NotificationCreateDto' });
@ApiPropertyOptional({ description: 'Additional notification data' })
@Optional({ nullable: true })
data?: any;
const NotificationUpdateSchema = z
.object({
readAt: isoDatetimeToDate.nullish().describe('Date when notification was read'),
})
.meta({ id: 'NotificationUpdateDto' });
@ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' })
readAt?: Date | null;
const NotificationUpdateAllSchema = z
.object({
ids: z.array(z.uuidv4()).min(1).describe('Notification IDs to update'),
readAt: isoDatetimeToDate.nullish().describe('Date when notifications were read'),
})
.meta({ id: 'NotificationUpdateAllDto' });
@ValidateUUID({ description: 'User ID to send notification to' })
userId!: string;
}
const NotificationDeleteAllSchema = z
.object({
ids: z.array(z.uuidv4()).min(1).describe('Notification IDs to delete'),
})
.meta({ id: 'NotificationDeleteAllDto' });
export class NotificationUpdateDto {
@ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' })
readAt?: Date | null;
}
export class TestEmailResponseDto extends createZodDto(TestEmailResponseSchema) {}
export class TemplateResponseDto extends createZodDto(TemplateResponseSchema) {}
export class TemplateDto extends createZodDto(TemplateSchema) {}
export class NotificationDto extends createZodDto(NotificationSchema) {}
export class NotificationSearchDto extends createZodDto(NotificationSearchSchema) {}
export class NotificationCreateDto extends createZodDto(NotificationCreateSchema) {}
export class NotificationUpdateDto extends createZodDto(NotificationUpdateSchema) {}
export class NotificationUpdateAllDto extends createZodDto(NotificationUpdateAllSchema) {}
export class NotificationDeleteAllDto extends createZodDto(NotificationDeleteAllSchema) {}
export class NotificationUpdateAllDto {
@ValidateUUID({ each: true, description: 'Notification IDs to update' })
@ArrayMinSize(1)
ids!: string[];
@ValidateDate({ optional: true, nullable: true, description: 'Date when notifications were read' })
readAt?: Date | null;
}
export class NotificationDeleteAllDto {
@ValidateUUID({ each: true, description: 'Notification IDs to delete' })
@ArrayMinSize(1)
ids!: string[];
}
export type MapNotification = {
type MapNotification = {
id: string;
createdAt: Date;
updateId?: string;
@ -123,6 +96,7 @@ export type MapNotification = {
description: string | null;
readAt: Date | null;
};
export const mapNotification = (notification: MapNotification): NotificationDto => {
return {
id: notification.id,

View File

@ -1,42 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export class AssetOcrResponseDto {
@ApiProperty({ type: 'string', format: 'uuid' })
id!: string;
const AssetOcrResponseSchema = z
.object({
assetId: z.uuidv4(),
boxScore: z.number().meta({ format: 'double' }).describe('Confidence score for text detection box'),
id: z.uuidv4(),
text: z.string().describe('Recognized text'),
textScore: z.number().meta({ format: 'double' }).describe('Confidence score for text recognition'),
x1: z.number().meta({ format: 'double' }).describe('Normalized x coordinate of box corner 1 (0-1)'),
x2: z.number().meta({ format: 'double' }).describe('Normalized x coordinate of box corner 2 (0-1)'),
x3: z.number().meta({ format: 'double' }).describe('Normalized x coordinate of box corner 3 (0-1)'),
x4: z.number().meta({ format: 'double' }).describe('Normalized x coordinate of box corner 4 (0-1)'),
y1: z.number().meta({ format: 'double' }).describe('Normalized y coordinate of box corner 1 (0-1)'),
y2: z.number().meta({ format: 'double' }).describe('Normalized y coordinate of box corner 2 (0-1)'),
y3: z.number().meta({ format: 'double' }).describe('Normalized y coordinate of box corner 3 (0-1)'),
y4: z.number().meta({ format: 'double' }).describe('Normalized y coordinate of box corner 4 (0-1)'),
})
.meta({ id: 'AssetOcrResponseDto' });
@ApiProperty({ type: 'string', format: 'uuid' })
assetId!: string;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized x coordinate of box corner 1 (0-1)' })
x1!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized y coordinate of box corner 1 (0-1)' })
y1!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized x coordinate of box corner 2 (0-1)' })
x2!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized y coordinate of box corner 2 (0-1)' })
y2!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized x coordinate of box corner 3 (0-1)' })
x3!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized y coordinate of box corner 3 (0-1)' })
y3!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized x coordinate of box corner 4 (0-1)' })
x4!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Normalized y coordinate of box corner 4 (0-1)' })
y4!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Confidence score for text detection box' })
boxScore!: number;
@ApiProperty({ type: 'number', format: 'double', description: 'Confidence score for text recognition' })
textScore!: number;
@ApiProperty({ type: 'string', description: 'Recognized text' })
text!: string;
}
export class AssetOcrResponseDto extends createZodDto(AssetOcrResponseSchema) {}

View File

@ -1,8 +1,10 @@
import { ValidateBoolean } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export class OnboardingDto {
@ValidateBoolean({ description: 'Is user onboarded' })
isOnboarded!: boolean;
}
const OnboardingSchema = z.object({
isOnboarded: z.boolean().describe('Is user onboarded'),
});
export class OnboardingDto extends createZodDto(OnboardingSchema) {}
export class OnboardingResponseDto extends OnboardingDto {}

View File

@ -1,26 +1,35 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
import { UserResponseDto } from 'src/dtos/user.dto';
import { createZodDto } from 'nestjs-zod';
import { UserResponseSchema } from 'src/dtos/user.dto';
import { PartnerDirection } from 'src/repositories/partner.repository';
import { ValidateEnum, ValidateUUID } from 'src/validation';
import * as z from 'zod';
export class PartnerCreateDto {
@ValidateUUID({ description: 'User ID to share with' })
sharedWithId!: string;
}
const PartnerDirectionSchema = z.enum(PartnerDirection).describe('Partner direction').meta({ id: 'PartnerDirection' });
export class PartnerUpdateDto {
@ApiProperty({ description: 'Show partner assets in timeline' })
@IsNotEmpty()
inTimeline!: boolean;
}
const PartnerCreateSchema = z
.object({
sharedWithId: z.uuidv4().describe('User ID to share with'),
})
.meta({ id: 'PartnerCreateDto' });
export class PartnerSearchDto {
@ValidateEnum({ enum: PartnerDirection, name: 'PartnerDirection', description: 'Partner direction' })
direction!: PartnerDirection;
}
const PartnerUpdateSchema = z
.object({
inTimeline: z.boolean().describe('Show partner assets in timeline'),
})
.meta({ id: 'PartnerUpdateDto' });
export class PartnerResponseDto extends UserResponseDto {
@ApiPropertyOptional({ description: 'Show in timeline' })
inTimeline?: boolean;
}
const PartnerSearchSchema = z
.object({
direction: PartnerDirectionSchema,
})
.meta({ id: 'PartnerSearchDto' });
const PartnerResponseSchema = UserResponseSchema.extend({
inTimeline: z.boolean().optional().describe('Show in timeline'),
})
.describe('Partner response')
.meta({ id: 'PartnerResponseDto' });
export class PartnerCreateDto extends createZodDto(PartnerCreateSchema) {}
export class PartnerUpdateDto extends createZodDto(PartnerUpdateSchema) {}
export class PartnerSearchDto extends createZodDto(PartnerSearchSchema) {}
export class PartnerResponseDto extends createZodDto(PartnerResponseSchema) {}

View File

@ -1,230 +1,184 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
import { Selectable } from 'kysely';
import { DateTime } from 'luxon';
import { createZodDto } from 'nestjs-zod';
import { AssetFace, Person } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SourceType } from 'src/enum';
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 { transformFaceBoundingBox } from 'src/utils/transform';
import {
IsDateStringFormat,
MaxDateString,
Optional,
ValidateBoolean,
ValidateEnum,
ValidateHexColor,
ValidateUUID,
} from 'src/validation';
import { emptyStringToNull, hexColor, stringToBool } from 'src/validation';
import * as z from 'zod';
export class PersonCreateDto {
@ApiPropertyOptional({ description: 'Person name' })
@Optional()
@IsString()
name?: string;
// Note: the mobile app cannot currently set the birth date to null.
@ApiProperty({ format: 'date', description: 'Person date of birth', required: false })
@MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
@IsDateStringFormat('yyyy-MM-dd')
@Optional({ nullable: true, emptyToNull: true })
birthDate?: string | null;
@ValidateBoolean({ optional: true, description: 'Person visibility (hidden)' })
isHidden?: boolean;
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
isFavorite?: boolean;
@ApiPropertyOptional({ description: 'Person color (hex)' })
@Optional({ emptyToNull: true, nullable: true })
@ValidateHexColor()
color?: string | null;
}
export class PersonUpdateDto extends PersonCreateDto {
@ValidateUUID({ optional: true, description: 'Asset ID used for feature face thumbnail' })
featureFaceAssetId?: string;
}
export class PeopleUpdateDto {
@ApiProperty({ description: 'People to update' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PeopleUpdateItem)
people!: PeopleUpdateItem[];
}
export class PeopleUpdateItem extends PersonUpdateDto {
@ApiProperty({ description: 'Person ID' })
@IsString()
@IsNotEmpty()
id!: string;
}
export class MergePersonDto {
@ValidateUUID({ each: true, description: 'Person IDs to merge' })
ids!: string[];
}
export class PersonSearchDto {
@ValidateBoolean({ optional: true, description: 'Include hidden people' })
withHidden?: boolean;
@ValidateUUID({ optional: true, description: 'Closest person ID for similarity search' })
closestPersonId?: string;
@ValidateUUID({ optional: true, description: 'Closest asset ID for similarity search' })
closestAssetId?: string;
@ApiPropertyOptional({ description: 'Page number for pagination', default: 1 })
@IsInt()
@Min(1)
@Type(() => Number)
page: number = 1;
@ApiPropertyOptional({ description: 'Number of items per page', default: 500 })
@IsInt()
@Min(1)
@Max(1000)
@Type(() => Number)
size: number = 500;
}
export class PersonResponseDto {
@ApiProperty({ description: 'Person ID' })
id!: string;
@ApiProperty({ description: 'Person name' })
name!: string;
@ApiProperty({ format: 'date', description: 'Person date of birth' })
birthDate!: string | null;
@ApiProperty({ description: 'Thumbnail path' })
thumbnailPath!: string;
@ApiProperty({ description: 'Is hidden' })
isHidden!: boolean;
@Property({
description: 'Last update date',
format: 'date-time',
history: new HistoryBuilder().added('v1.107.0').stable('v2'),
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())
.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)'),
})
updatedAt?: string;
@Property({ description: 'Is favorite', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
isFavorite?: boolean;
@Property({ description: 'Person color (hex)', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
color?: string;
}
.meta({ id: 'PersonCreateDto' });
export class PersonWithFacesResponseDto extends PersonResponseDto {
@ApiProperty({ description: 'Face detections' })
faces!: AssetFaceWithoutPersonResponseDto[];
}
const PersonUpdateSchema = PersonCreateSchema.extend({
featureFaceAssetId: z.uuidv4().optional().describe('Asset ID used for feature face thumbnail'),
}).meta({ id: 'PersonUpdateDto' });
export class AssetFaceWithoutPersonResponseDto {
@ValidateUUID({ description: 'Face ID' })
id!: string;
@ApiProperty({ type: 'integer', description: 'Image height in pixels' })
imageHeight!: number;
@ApiProperty({ type: 'integer', description: 'Image width in pixels' })
imageWidth!: number;
@ApiProperty({ type: 'integer', description: 'Bounding box X1 coordinate' })
boundingBoxX1!: number;
@ApiProperty({ type: 'integer', description: 'Bounding box X2 coordinate' })
boundingBoxX2!: number;
@ApiProperty({ type: 'integer', description: 'Bounding box Y1 coordinate' })
boundingBoxY1!: number;
@ApiProperty({ type: 'integer', description: 'Bounding box Y2 coordinate' })
boundingBoxY2!: number;
@ValidateEnum({ enum: SourceType, name: 'SourceType', optional: true, description: 'Face detection source type' })
sourceType?: SourceType;
}
const PeopleUpdateItemSchema = PersonUpdateSchema.extend({
id: z.string().describe('Person ID'),
}).meta({ id: 'PeopleUpdateItem' });
export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto {
@ApiProperty({ description: 'Person associated with face' })
person!: PersonResponseDto | null;
}
export class AssetFaceUpdateDto {
@ApiProperty({ description: 'Face update items' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetFaceUpdateItem)
data!: AssetFaceUpdateItem[];
}
export class FaceDto {
@ValidateUUID({ description: 'Face ID' })
id!: string;
}
export class AssetFaceUpdateItem {
@ValidateUUID({ description: 'Person ID' })
personId!: string;
@ValidateUUID({ description: 'Asset ID' })
assetId!: string;
}
export class AssetFaceCreateDto extends AssetFaceUpdateItem {
@ApiProperty({ type: 'integer', description: 'Image width in pixels' })
@IsNotEmpty()
@IsNumber()
imageWidth!: number;
@ApiProperty({ type: 'integer', description: 'Image height in pixels' })
@IsNotEmpty()
@IsNumber()
imageHeight!: number;
@ApiProperty({ type: 'integer', description: 'Face bounding box X coordinate' })
@IsNotEmpty()
@IsNumber()
x!: number;
@ApiProperty({ type: 'integer', description: 'Face bounding box Y coordinate' })
@IsNotEmpty()
@IsNumber()
y!: number;
@ApiProperty({ type: 'integer', description: 'Face bounding box width' })
@IsNotEmpty()
@IsNumber()
width!: number;
@ApiProperty({ type: 'integer', description: 'Face bounding box height' })
@IsNotEmpty()
@IsNumber()
height!: number;
}
export class AssetFaceDeleteDto {
@ApiProperty({ description: 'Force delete even if person has other faces' })
@IsNotEmpty()
force!: boolean;
}
export class PersonStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of assets' })
assets!: number;
}
export class PeopleResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of people' })
total!: number;
@ApiProperty({ type: 'integer', description: 'Number of hidden people' })
hidden!: number;
@ApiProperty({ description: 'List of people' })
people!: PersonResponseDto[];
// TODO: make required after a few versions
@Property({
description: 'Whether there are more pages',
history: new HistoryBuilder().added('v1.110.0').stable('v2'),
const PeopleUpdateSchema = z
.object({
people: z.array(PeopleUpdateItemSchema).describe('People to update'),
})
hasNextPage?: boolean;
}
.meta({ id: 'PeopleUpdateDto' });
const MergePersonSchema = z
.object({
ids: z.array(z.uuidv4()).describe('Person IDs to merge'),
})
.meta({ id: 'MergePersonDto' });
const PersonSearchSchema = z
.object({
withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
})
.meta({ id: 'PersonSearchDto' });
const PersonResponseSchema = z
.object({
id: z.string().describe('Person ID'),
name: z.string().describe('Person name'),
// TODO: use `isoDateToDate` when using `ZodSerializerDto` on the controllers.
birthDate: z.string().meta({ format: 'date' }).describe('Person date of birth').nullable(),
thumbnailPath: z.string().describe('Thumbnail path'),
isHidden: z.boolean().describe('Is hidden'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
updatedAt: z
.string()
.meta({ format: 'date-time' })
.optional()
.describe('Last update date')
.meta({ ...new HistoryBuilder().added('v1.107.0').stable('v2').getExtensions() }),
isFavorite: z
.boolean()
.optional()
.describe('Is favorite')
.meta({ ...new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions() }),
color: z
.string()
.optional()
.describe('Person color (hex)')
.meta({ ...new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions() }),
})
.meta({ id: 'PersonResponseDto' });
export class PersonCreateDto extends createZodDto(PersonCreateSchema) {}
export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {}
export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {}
export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
export const AssetFaceWithoutPersonResponseSchema = z
.object({
id: z.uuidv4().describe('Face ID'),
imageHeight: z.int().min(0).describe('Image height in pixels'),
imageWidth: z.int().min(0).describe('Image width in pixels'),
boundingBoxX1: z.int().describe('Bounding box X1 coordinate'),
boundingBoxX2: z.int().describe('Bounding box X2 coordinate'),
boundingBoxY1: z.int().describe('Bounding box Y1 coordinate'),
boundingBoxY2: z.int().describe('Bounding box Y2 coordinate'),
sourceType: SourceTypeSchema.optional(),
})
.describe('Asset face without person')
.meta({ id: 'AssetFaceWithoutPersonResponseDto' });
class AssetFaceWithoutPersonResponseDto extends createZodDto(AssetFaceWithoutPersonResponseSchema) {}
export const PersonWithFacesResponseSchema = PersonResponseSchema.extend({
faces: z.array(AssetFaceWithoutPersonResponseSchema),
}).meta({ id: 'PersonWithFacesResponseDto' });
export class PersonWithFacesResponseDto extends createZodDto(PersonWithFacesResponseSchema) {}
const AssetFaceResponseSchema = AssetFaceWithoutPersonResponseSchema.extend({
person: PersonResponseSchema.nullable(),
}).meta({ id: 'AssetFaceResponseDto' });
export class AssetFaceResponseDto extends createZodDto(AssetFaceResponseSchema) {}
const AssetFaceUpdateItemSchema = z
.object({
personId: z.uuidv4().describe('Person ID'),
assetId: z.uuidv4().describe('Asset ID'),
})
.meta({ id: 'AssetFaceUpdateItem' });
const AssetFaceUpdateSchema = z
.object({
data: z.array(AssetFaceUpdateItemSchema).describe('Face update items'),
})
.meta({ id: 'AssetFaceUpdateDto' });
const FaceSchema = z
.object({
id: z.uuidv4().describe('Face ID'),
})
.meta({ id: 'FaceDto' });
const AssetFaceCreateSchema = AssetFaceUpdateItemSchema.extend({
imageWidth: z.int().describe('Image width in pixels'),
imageHeight: z.int().describe('Image height in pixels'),
x: z.int().describe('Face bounding box X coordinate'),
y: z.int().describe('Face bounding box Y coordinate'),
width: z.int().describe('Face bounding box width'),
height: z.int().describe('Face bounding box height'),
}).meta({ id: 'AssetFaceCreateDto' });
const AssetFaceDeleteSchema = z
.object({
force: z.boolean().describe('Force delete even if person has other faces'),
})
.meta({ id: 'AssetFaceDeleteDto' });
const PersonStatisticsResponseSchema = z
.object({
assets: z.int().describe('Number of assets'),
})
.meta({ id: 'PersonStatisticsResponseDto' });
export class AssetFaceUpdateDto extends createZodDto(AssetFaceUpdateSchema) {}
export class FaceDto extends createZodDto(FaceSchema) {}
export class AssetFaceCreateDto extends createZodDto(AssetFaceCreateSchema) {}
export class AssetFaceDeleteDto extends createZodDto(AssetFaceDeleteSchema) {}
export class PersonStatisticsResponseDto extends createZodDto(PersonStatisticsResponseSchema) {}
const PeopleResponseSchema = z
.object({
total: z.int().min(0).describe('Total number of people'),
hidden: z.int().min(0).describe('Number of hidden people'),
people: z.array(PersonResponseSchema),
// TODO: make required after a few versions
hasNextPage: z
.boolean()
.optional()
.describe('Whether there are more pages')
.meta({ ...new HistoryBuilder().added('v1.110.0').stable('v2').getExtensions() }),
})
.describe('People response');
export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {}
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
return {

View File

@ -1,128 +1,56 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsSemVer,
IsString,
Matches,
ValidateNested,
} from 'class-validator';
import { PluginContext } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { PluginContextSchema } from 'src/enum';
import { JSONSchemaSchema } from 'src/types/plugin-schema.types';
import * as z from 'zod';
class PluginManifestWasmDto {
@ApiProperty({ description: 'WASM file path' })
@IsString()
@IsNotEmpty()
path!: string;
}
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
const semverRegex =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
class PluginManifestFilterDto {
@ApiProperty({ description: 'Filter method name' })
@IsString()
@IsNotEmpty()
methodName!: string;
@ApiProperty({ description: 'Filter title' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiProperty({ description: 'Filter description' })
@IsString()
@IsNotEmpty()
description!: string;
@ApiProperty({ description: 'Supported contexts', enum: PluginContext, isArray: true })
@IsArray()
@ArrayMinSize(1)
@IsEnum(PluginContext, { each: true })
supportedContexts!: PluginContext[];
@ApiPropertyOptional({ description: 'Filter schema' })
@IsObject()
@IsOptional()
schema?: JSONSchema;
}
class PluginManifestActionDto {
@ApiProperty({ description: 'Action method name' })
@IsString()
@IsNotEmpty()
methodName!: string;
@ApiProperty({ description: 'Action title' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiProperty({ description: 'Action description' })
@IsString()
@IsNotEmpty()
description!: string;
@ArrayMinSize(1)
@ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true, description: 'Supported contexts' })
supportedContexts!: PluginContext[];
@ApiPropertyOptional({ description: 'Action schema' })
@IsObject()
@IsOptional()
schema?: JSONSchema;
}
export class PluginManifestDto {
@ApiProperty({ description: 'Plugin name (lowercase, numbers, hyphens only)' })
@IsString()
@IsNotEmpty()
@Matches(/^[a-z0-9-]+[a-z0-9]$/, {
message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen',
const PluginManifestWasmSchema = z
.object({
path: z.string().describe('WASM file path'),
})
name!: string;
.meta({ id: 'PluginManifestWasmDto' });
@ApiProperty({ description: 'Plugin version (semver)' })
@IsString()
@IsNotEmpty()
@IsSemVer()
version!: string;
const PluginManifestFilterSchema = z
.object({
methodName: z.string().describe('Filter method name'),
title: z.string().describe('Filter title'),
description: z.string().describe('Filter description'),
supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'),
schema: JSONSchemaSchema.optional(),
})
.meta({ id: 'PluginManifestFilterDto' });
@ApiProperty({ description: 'Plugin title' })
@IsString()
@IsNotEmpty()
title!: string;
const PluginManifestActionSchema = z
.object({
methodName: z.string().describe('Action method name'),
title: z.string().describe('Action title'),
description: z.string().describe('Action description'),
supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'),
schema: JSONSchemaSchema.optional(),
})
.meta({ id: 'PluginManifestActionDto' });
@ApiProperty({ description: 'Plugin description' })
@IsString()
@IsNotEmpty()
description!: string;
export const PluginManifestSchema = z
.object({
name: z
.string()
.min(1)
.regex(
pluginNameRegex,
'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen',
)
.describe('Plugin name (lowercase, numbers, hyphens only)'),
version: z.string().regex(semverRegex).describe('Plugin version (semver)'),
title: z.string().describe('Plugin title'),
description: z.string().describe('Plugin description'),
author: z.string().describe('Plugin author'),
wasm: PluginManifestWasmSchema,
filters: z.array(PluginManifestFilterSchema).optional().describe('Plugin filters'),
actions: z.array(PluginManifestActionSchema).optional().describe('Plugin actions'),
})
.meta({ id: 'PluginManifestDto' });
@ApiProperty({ description: 'Plugin author' })
@IsString()
@IsNotEmpty()
author!: string;
@ApiProperty({ description: 'WASM configuration' })
@ValidateNested()
@Type(() => PluginManifestWasmDto)
wasm!: PluginManifestWasmDto;
@ApiPropertyOptional({ description: 'Plugin filters' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestFilterDto)
@IsOptional()
filters?: PluginManifestFilterDto[];
@ApiPropertyOptional({ description: 'Plugin actions' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestActionDto)
@IsOptional()
actions?: PluginManifestActionDto[];
}
export class PluginManifestDto extends createZodDto(PluginManifestSchema) {}

View File

@ -1,84 +1,59 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
import { PluginContextSchema, PluginTriggerTypeSchema } from 'src/enum';
import { JSONSchemaSchema } from 'src/types/plugin-schema.types';
import * as z from 'zod';
export class PluginTriggerResponseDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Trigger type' })
type!: PluginTriggerType;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', description: 'Context type' })
contextType!: PluginContextType;
}
const PluginTriggerResponseSchema = z
.object({
type: PluginTriggerTypeSchema,
contextType: PluginContextSchema,
})
.meta({ id: 'PluginTriggerResponseDto' });
export class PluginResponseDto {
@ApiProperty({ description: 'Plugin ID' })
id!: string;
@ApiProperty({ description: 'Plugin name' })
name!: string;
@ApiProperty({ description: 'Plugin title' })
title!: string;
@ApiProperty({ description: 'Plugin description' })
description!: string;
@ApiProperty({ description: 'Plugin author' })
author!: string;
@ApiProperty({ description: 'Plugin version' })
version!: string;
@ApiProperty({ description: 'Creation date' })
createdAt!: string;
@ApiProperty({ description: 'Last update date' })
updatedAt!: string;
@ApiProperty({ description: 'Plugin filters' })
filters!: PluginFilterResponseDto[];
@ApiProperty({ description: 'Plugin actions' })
actions!: PluginActionResponseDto[];
}
const PluginFilterResponseSchema = z
.object({
id: z.string().describe('Filter ID'),
pluginId: z.string().describe('Plugin ID'),
methodName: z.string().describe('Method name'),
title: z.string().describe('Filter title'),
description: z.string().describe('Filter description'),
supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'),
schema: JSONSchemaSchema.nullable().describe('Filter schema'),
})
.meta({ id: 'PluginFilterResponseDto' });
export class PluginFilterResponseDto {
@ApiProperty({ description: 'Filter ID' })
id!: string;
@ApiProperty({ description: 'Plugin ID' })
pluginId!: string;
@ApiProperty({ description: 'Method name' })
methodName!: string;
@ApiProperty({ description: 'Filter title' })
title!: string;
@ApiProperty({ description: 'Filter description' })
description!: string;
const PluginActionResponseSchema = z
.object({
id: z.string().describe('Action ID'),
pluginId: z.string().describe('Plugin ID'),
methodName: z.string().describe('Method name'),
title: z.string().describe('Action title'),
description: z.string().describe('Action description'),
supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'),
schema: JSONSchemaSchema.nullable().describe('Action schema'),
})
.meta({ id: 'PluginActionResponseDto' });
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' })
supportedContexts!: PluginContextType[];
@ApiProperty({ description: 'Filter schema' })
schema!: JSONSchema | null;
}
const PluginResponseSchema = z
.object({
id: z.string().describe('Plugin ID'),
name: z.string().describe('Plugin name'),
title: z.string().describe('Plugin title'),
description: z.string().describe('Plugin description'),
author: z.string().describe('Plugin author'),
version: z.string().describe('Plugin version'),
createdAt: z.string().describe('Creation date'),
updatedAt: z.string().describe('Last update date'),
filters: z.array(PluginFilterResponseSchema).describe('Plugin filters'),
actions: z.array(PluginActionResponseSchema).describe('Plugin actions'),
})
.meta({ id: 'PluginResponseDto' });
export class PluginActionResponseDto {
@ApiProperty({ description: 'Action ID' })
id!: string;
@ApiProperty({ description: 'Plugin ID' })
pluginId!: string;
@ApiProperty({ description: 'Method name' })
methodName!: string;
@ApiProperty({ description: 'Action title' })
title!: string;
@ApiProperty({ description: 'Action description' })
description!: string;
export class PluginTriggerResponseDto extends createZodDto(PluginTriggerResponseSchema) {}
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' })
supportedContexts!: PluginContextType[];
@ApiProperty({ description: 'Action schema' })
schema!: JSONSchema | null;
}
export class PluginInstallDto {
@ApiProperty({ description: 'Path to plugin manifest file' })
@IsString()
@IsNotEmpty()
manifestPath!: string;
}
export type MapPlugin = {
type MapPlugin = {
id: string;
name: string;
title: string;

View File

@ -1,79 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { QueueResponseDto, QueueStatisticsDto } from 'src/dtos/queue.dto';
import { createZodDto } from 'nestjs-zod';
import { QueueResponseDto, QueueStatisticsSchema } from 'src/dtos/queue.dto';
import { QueueName } from 'src/enum';
import * as z from 'zod';
export class QueueStatusLegacyDto {
@ApiProperty({ description: 'Whether the queue is currently active (has running jobs)' })
isActive!: boolean;
@ApiProperty({ description: 'Whether the queue is paused' })
isPaused!: boolean;
}
const QueueStatusLegacySchema = z
.object({
isActive: z.boolean().describe('Whether the queue is currently active (has running jobs)'),
isPaused: z.boolean().describe('Whether the queue is paused'),
})
.meta({ id: 'QueueStatusLegacyDto' });
export class QueueResponseLegacyDto {
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
queueStatus!: QueueStatusLegacyDto;
const QueueResponseLegacySchema = z
.object({
queueStatus: QueueStatusLegacySchema,
jobCounts: QueueStatisticsSchema,
})
.meta({ id: 'QueueResponseLegacyDto' });
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
jobCounts!: QueueStatisticsDto;
}
const QueuesResponseLegacySchema = z
.object({
[QueueName.ThumbnailGeneration]: QueueResponseLegacySchema,
[QueueName.MetadataExtraction]: QueueResponseLegacySchema,
[QueueName.VideoConversion]: QueueResponseLegacySchema,
[QueueName.SmartSearch]: QueueResponseLegacySchema,
[QueueName.StorageTemplateMigration]: QueueResponseLegacySchema,
[QueueName.Migration]: QueueResponseLegacySchema,
[QueueName.BackgroundTask]: QueueResponseLegacySchema,
[QueueName.Search]: QueueResponseLegacySchema,
[QueueName.DuplicateDetection]: QueueResponseLegacySchema,
[QueueName.FaceDetection]: QueueResponseLegacySchema,
[QueueName.FacialRecognition]: QueueResponseLegacySchema,
[QueueName.Sidecar]: QueueResponseLegacySchema,
[QueueName.Library]: QueueResponseLegacySchema,
[QueueName.Notification]: QueueResponseLegacySchema,
[QueueName.BackupDatabase]: QueueResponseLegacySchema,
[QueueName.Ocr]: QueueResponseLegacySchema,
[QueueName.Workflow]: QueueResponseLegacySchema,
[QueueName.Editor]: QueueResponseLegacySchema,
})
.meta({ id: 'QueuesResponseLegacyDto' });
export class QueuesResponseLegacyDto implements Record<QueueName, QueueResponseLegacyDto> {
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.ThumbnailGeneration]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.MetadataExtraction]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.VideoConversion]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.SmartSearch]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.StorageTemplateMigration]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Migration]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.BackgroundTask]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Search]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.DuplicateDetection]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.FaceDetection]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.FacialRecognition]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Sidecar]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Library]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Notification]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.BackupDatabase]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Ocr]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Workflow]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Editor]!: QueueResponseLegacyDto;
}
export class QueueResponseLegacyDto extends createZodDto(QueueResponseLegacySchema) {}
export class QueuesResponseLegacyDto extends createZodDto(QueuesResponseLegacySchema) {}
export const mapQueueLegacy = (response: QueueResponseDto): QueueResponseLegacyDto => {
return {

View File

@ -1,82 +1,76 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { createZodDto } from 'nestjs-zod';
import { HistoryBuilder } from 'src/decorators';
import { JobName, QueueCommand, QueueJobStatus, QueueName } from 'src/enum';
import { ValidateBoolean, ValidateEnum } from 'src/validation';
import { JobNameSchema, QueueCommandSchema, QueueJobStatusSchema, QueueNameSchema } from 'src/enum';
import * as z from 'zod';
export class QueueNameParamDto {
@ValidateEnum({ enum: QueueName, name: 'QueueName', description: 'Queue name' })
name!: QueueName;
}
export class QueueCommandDto {
@ValidateEnum({ enum: QueueCommand, name: 'QueueCommand', description: 'Queue command to execute' })
command!: QueueCommand;
@ValidateBoolean({ optional: true, description: 'Force the command execution (if applicable)' })
force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit
}
export class QueueUpdateDto {
@ValidateBoolean({ optional: true, description: 'Whether to pause the queue' })
isPaused?: boolean;
}
export class QueueDeleteDto {
@ValidateBoolean({
optional: true,
description: 'If true, will also remove failed jobs from the queue.',
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
const QueueNameParamSchema = z
.object({
name: QueueNameSchema,
})
failed?: boolean;
}
.meta({ id: 'QueueNameParamDto' });
export class QueueJobSearchDto {
@ValidateEnum({
enum: QueueJobStatus,
name: 'QueueJobStatus',
optional: true,
each: true,
description: 'Filter jobs by status',
const QueueCommandSchemaDto = z
.object({
command: QueueCommandSchema,
force: z.boolean().optional().describe('Force the command execution (if applicable)'),
})
status?: QueueJobStatus[];
}
export class QueueJobResponseDto {
@ApiPropertyOptional({ description: 'Job ID' })
id?: string;
.meta({ id: 'QueueCommandDto' });
@ValidateEnum({ enum: JobName, name: 'JobName', description: 'Job name' })
name!: JobName;
const QueueUpdateSchema = z
.object({
isPaused: z.boolean().optional().describe('Whether to pause the queue'),
})
.meta({ id: 'QueueUpdateDto' });
@ApiProperty({ description: 'Job data payload', type: Object })
data!: object;
const QueueDeleteSchema = z
.object({
failed: z
.boolean()
.optional()
.describe('If true, will also remove failed jobs from the queue.')
.meta({ ...new HistoryBuilder().added('v2.4.0').alpha('v2.4.0').getExtensions() }),
})
.meta({ id: 'QueueDeleteDto' });
@ApiProperty({ type: 'integer', description: 'Job creation timestamp' })
timestamp!: number;
}
const QueueJobSearchSchema = z
.object({
status: z.array(QueueJobStatusSchema).optional().describe('Filter jobs by status'),
})
.meta({ id: 'QueueJobSearchDto' });
export class QueueStatisticsDto {
@ApiProperty({ type: 'integer', description: 'Number of active jobs' })
active!: number;
@ApiProperty({ type: 'integer', description: 'Number of completed jobs' })
completed!: number;
@ApiProperty({ type: 'integer', description: 'Number of failed jobs' })
failed!: number;
@ApiProperty({ type: 'integer', description: 'Number of delayed jobs' })
delayed!: number;
@ApiProperty({ type: 'integer', description: 'Number of waiting jobs' })
waiting!: number;
@ApiProperty({ type: 'integer', description: 'Number of paused jobs' })
paused!: number;
}
const QueueJobResponseSchema = z
.object({
id: z.string().optional().describe('Job ID'),
name: JobNameSchema,
data: z.record(z.string(), z.unknown()).describe('Job data payload'),
timestamp: z.int().describe('Job creation timestamp'),
})
.meta({ id: 'QueueJobResponseDto' });
export class QueueResponseDto {
@ValidateEnum({ enum: QueueName, name: 'QueueName', description: 'Queue name' })
name!: QueueName;
export const QueueStatisticsSchema = z
.object({
active: z.int().describe('Number of active jobs'),
completed: z.int().describe('Number of completed jobs'),
failed: z.int().describe('Number of failed jobs'),
delayed: z.int().describe('Number of delayed jobs'),
waiting: z.int().describe('Number of waiting jobs'),
paused: z.int().describe('Number of paused jobs'),
})
.meta({ id: 'QueueStatisticsDto' });
@ValidateBoolean({ description: 'Whether the queue is paused' })
isPaused!: boolean;
const QueueResponseSchema = z
.object({
name: QueueNameSchema,
isPaused: z.boolean().describe('Whether the queue is paused'),
statistics: QueueStatisticsSchema,
})
.meta({ id: 'QueueResponseDto' });
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
statistics!: QueueStatisticsDto;
}
export class QueueNameParamDto extends createZodDto(QueueNameParamSchema) {}
export class QueueCommandDto extends createZodDto(QueueCommandSchemaDto) {}
export class QueueUpdateDto extends createZodDto(QueueUpdateSchema) {}
export class QueueDeleteDto extends createZodDto(QueueDeleteSchema) {}
export class QueueJobSearchDto extends createZodDto(QueueJobSearchSchema) {}
export class QueueJobResponseDto extends createZodDto(QueueJobResponseSchema) {}
export class QueueStatisticsDto extends createZodDto(QueueStatisticsSchema) {}
export class QueueResponseDto extends createZodDto(QueueResponseSchema) {}

View File

@ -1,282 +1,157 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Place } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
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 * as z from 'zod';
class BaseSearchDto {
@ValidateUUID({ optional: true, nullable: true, description: 'Library ID to filter by' })
libraryId?: string | null;
const BaseSearchSchema = z.object({
libraryId: z.uuidv4().nullish().describe('Library ID to filter by'),
deviceId: z.string().optional().describe('Device ID to filter by'),
type: AssetTypeSchema.optional(),
isEncoded: z.boolean().optional().describe('Filter by encoded status'),
isFavorite: z.boolean().optional().describe('Filter by favorite status'),
isMotion: z.boolean().optional().describe('Filter by motion photo status'),
isOffline: z.boolean().optional().describe('Filter by offline status'),
visibility: AssetVisibilitySchema.optional(),
createdBefore: isoDatetimeToDate.optional().describe('Filter by creation date (before)'),
createdAfter: isoDatetimeToDate.optional().describe('Filter by creation date (after)'),
updatedBefore: isoDatetimeToDate.optional().describe('Filter by update date (before)'),
updatedAfter: isoDatetimeToDate.optional().describe('Filter by update date (after)'),
trashedBefore: isoDatetimeToDate.optional().describe('Filter by trash date (before)'),
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'),
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'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z
.number()
.min(-1)
.max(5)
.nullish()
.describe('Filter by rating [1-5], or null for unrated')
.meta({
...new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
.getExtensions(),
}),
ocr: z.string().optional().describe('Filter by OCR text content'),
});
@ApiPropertyOptional({ description: 'Device ID to filter by' })
@IsString()
@IsNotEmpty()
@Optional()
deviceId?: string;
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
});
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', optional: true, description: 'Asset type filter' })
type?: AssetType;
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
withStacked: z.boolean().optional().describe('Include stacked assets'),
withPeople: z.boolean().optional().describe('Include assets with people'),
}).meta({ id: 'RandomSearchDto' });
@ValidateBoolean({ optional: true, description: 'Filter by encoded status' })
isEncoded?: boolean;
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' });
@ValidateBoolean({ optional: true, description: 'Filter by favorite status' })
isFavorite?: boolean;
const MetadataSearchSchema = RandomSearchSchema.extend({
id: z.uuidv4().optional().describe('Filter by asset ID'),
deviceAssetId: z.string().optional().describe('Filter by device asset ID'),
description: z.string().trim().optional().describe('Filter by description text'),
checksum: z.string().optional().describe('Filter by file checksum'),
originalFileName: z.string().trim().optional().describe('Filter by original file name'),
originalPath: z.string().optional().describe('Filter by original file path'),
previewPath: z.string().optional().describe('Filter by preview file path'),
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.number().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' });
@ValidateBoolean({ optional: true, description: 'Filter by motion photo status' })
isMotion?: boolean;
const StatisticsSearchSchema = BaseSearchSchema.extend({
description: z.string().trim().optional().describe('Filter by description text'),
}).meta({ id: 'StatisticsSearchDto' });
@ValidateBoolean({ optional: true, description: 'Filter by offline status' })
isOffline?: boolean;
const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'),
page: z.number().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' });
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true, description: 'Filter by visibility' })
visibility?: AssetVisibility;
@ValidateDate({ optional: true, description: 'Filter by creation date (before)' })
createdBefore?: Date;
@ValidateDate({ optional: true, description: 'Filter by creation date (after)' })
createdAfter?: Date;
@ValidateDate({ optional: true, description: 'Filter by update date (before)' })
updatedBefore?: Date;
@ValidateDate({ optional: true, description: 'Filter by update date (after)' })
updatedAfter?: Date;
@ValidateDate({ optional: true, description: 'Filter by trash date (before)' })
trashedBefore?: Date;
@ValidateDate({ optional: true, description: 'Filter by trash date (after)' })
trashedAfter?: Date;
@ValidateDate({ optional: true, description: 'Filter by taken date (before)' })
takenBefore?: Date;
@ValidateDate({ optional: true, description: 'Filter by taken date (after)' })
takenAfter?: Date;
@ApiPropertyOptional({ description: 'Filter by city name' })
@IsString()
@Optional({ nullable: true, emptyToNull: true })
city?: string | null;
@ApiPropertyOptional({ description: 'Filter by state/province name' })
@IsString()
@Optional({ nullable: true, emptyToNull: true })
state?: string | null;
@ApiPropertyOptional({ description: 'Filter by country name' })
@IsString()
@IsNotEmpty()
@Optional({ nullable: true, emptyToNull: true })
country?: string | null;
@ApiPropertyOptional({ description: 'Filter by camera make' })
@IsString()
@Optional({ nullable: true, emptyToNull: true })
make?: string;
@ApiPropertyOptional({ description: 'Filter by camera model' })
@IsString()
@Optional({ nullable: true, emptyToNull: true })
model?: string | null;
@ApiPropertyOptional({ description: 'Filter by lens model' })
@IsString()
@Optional({ nullable: true, emptyToNull: true })
lensModel?: string | null;
@ValidateBoolean({ optional: true, description: 'Filter assets not in any album' })
isNotInAlbum?: boolean;
@ValidateUUID({ each: true, optional: true, description: 'Filter by person IDs' })
personIds?: string[];
@ValidateUUID({ each: true, optional: true, nullable: true, description: 'Filter by tag IDs' })
tagIds?: string[] | null;
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
albumIds?: string[];
@Property({
type: 'number',
description: 'Filter by rating [1-5], or null for unrated',
minimum: -1,
maximum: 5,
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
const SearchPlacesSchema = z
.object({
name: z.string().describe('Place name to search for'),
})
@Optional({ nullable: true })
@IsInt()
@Max(5)
@Min(-1)
rating?: number | null;
.meta({ id: 'SearchPlacesDto' });
@ApiPropertyOptional({ description: 'Filter by OCR text content' })
@IsString()
@IsNotEmpty()
@Optional()
ocr?: string;
}
class BaseSearchWithResultsDto extends BaseSearchDto {
@ValidateBoolean({ optional: true, description: 'Include deleted assets' })
withDeleted?: boolean;
@ValidateBoolean({ optional: true, description: 'Include EXIF data in response' })
withExif?: boolean;
@ApiPropertyOptional({ type: 'number', description: 'Number of results to return', minimum: 1, maximum: 1000 })
@IsInt()
@Min(1)
@Max(1000)
@Type(() => Number)
@Optional()
size?: number;
}
export class RandomSearchDto extends BaseSearchWithResultsDto {
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
withStacked?: boolean;
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
withPeople?: boolean;
}
export class LargeAssetSearchDto extends BaseSearchWithResultsDto {
@ApiPropertyOptional({ type: 'integer', description: 'Minimum file size in bytes', minimum: 0 })
@Optional()
@IsInt()
@Min(0)
@Type(() => Number)
minFileSize?: number;
}
export class MetadataSearchDto extends RandomSearchDto {
@ValidateUUID({ optional: true, description: 'Filter by asset ID' })
id?: string;
@ApiPropertyOptional({ description: 'Filter by device asset ID' })
@IsString()
@IsNotEmpty()
@Optional()
deviceAssetId?: string;
@ValidateString({ optional: true, trim: true, description: 'Filter by description text' })
description?: string;
@ApiPropertyOptional({ description: 'Filter by file checksum' })
@IsString()
@IsNotEmpty()
@Optional()
checksum?: string;
@ValidateString({ optional: true, trim: true, description: 'Filter by original file name' })
originalFileName?: string;
@ApiPropertyOptional({ description: 'Filter by original file path' })
@IsString()
@IsNotEmpty()
@Optional()
originalPath?: string;
@ApiPropertyOptional({ description: 'Filter by preview file path' })
@IsString()
@IsNotEmpty()
@Optional()
previewPath?: string;
@ApiPropertyOptional({ description: 'Filter by thumbnail file path' })
@IsString()
@IsNotEmpty()
@Optional()
thumbnailPath?: string;
@ApiPropertyOptional({ description: 'Filter by encoded video file path' })
@IsString()
@IsNotEmpty()
@Optional()
encodedVideoPath?: string;
@ValidateEnum({
enum: AssetOrder,
name: 'AssetOrder',
optional: true,
default: AssetOrder.Desc,
description: 'Sort order',
const SearchPeopleSchema = z
.object({
name: z.string().describe('Person name to search for'),
withHidden: stringToBool.optional().describe('Include hidden people'),
})
order?: AssetOrder;
.meta({ id: 'SearchPeopleDto' });
@ApiPropertyOptional({ type: 'number', description: 'Page number', minimum: 1 })
@IsInt()
@Min(1)
@Type(() => Number)
@Optional()
page?: number;
const PlacesResponseSchema = z
.object({
name: z.string().describe('Place name'),
latitude: z.number().describe('Latitude coordinate'),
longitude: z.number().describe('Longitude coordinate'),
admin1name: z.string().optional().describe('Administrative level 1 name (state/province)'),
admin2name: z.string().optional().describe('Administrative level 2 name (county/district)'),
})
.meta({ id: 'PlacesResponseDto' });
export enum SearchSuggestionType {
COUNTRY = 'country',
STATE = 'state',
CITY = 'city',
CAMERA_MAKE = 'camera-make',
CAMERA_MODEL = 'camera-model',
CAMERA_LENS_MODEL = 'camera-lens-model',
}
export class StatisticsSearchDto extends BaseSearchDto {
@ValidateString({ optional: true, trim: true, description: 'Filter by description text' })
description?: string;
}
const SearchSuggestionTypeSchema = z
.enum(SearchSuggestionType)
.describe('Suggestion type')
.meta({ id: 'SearchSuggestionType' });
export class SmartSearchDto extends BaseSearchWithResultsDto {
@ValidateString({ optional: true, trim: true, description: 'Natural language search query' })
query?: string;
const SearchSuggestionRequestSchema = z
.object({
type: SearchSuggestionTypeSchema,
country: z.string().optional().describe('Filter by country'),
state: z.string().optional().describe('Filter by state/province'),
make: z.string().optional().describe('Filter by camera make'),
model: z.string().optional().describe('Filter by camera model'),
lensModel: z.string().optional().describe('Filter by lens model'),
includeNull: stringToBool
.optional()
.describe('Include null values in suggestions')
.meta({ ...new HistoryBuilder().added('v1.111.0').stable('v2').getExtensions() }),
})
.meta({ id: 'SearchSuggestionRequestDto' });
@ValidateUUID({ optional: true, description: 'Asset ID to use as search reference' })
queryAssetId?: string;
@ApiPropertyOptional({ description: 'Search language code' })
@IsString()
@IsNotEmpty()
@Optional()
language?: string;
@ApiPropertyOptional({ type: 'number', description: 'Page number', minimum: 1 })
@IsInt()
@Min(1)
@Type(() => Number)
@Optional()
page?: number;
}
export class SearchPlacesDto {
@ApiProperty({ description: 'Place name to search for' })
@IsString()
@IsNotEmpty()
name!: string;
}
export class SearchPeopleDto {
@ApiProperty({ description: 'Person name to search for' })
@IsString()
@IsNotEmpty()
name!: string;
@ValidateBoolean({ optional: true, description: 'Include hidden people' })
withHidden?: boolean;
}
export class PlacesResponseDto {
@ApiProperty({ description: 'Place name' })
name!: string;
@ApiProperty({ type: 'number', description: 'Latitude coordinate' })
latitude!: number;
@ApiProperty({ type: 'number', description: 'Longitude coordinate' })
longitude!: number;
@ApiPropertyOptional({ description: 'Administrative level 1 name (state/province)' })
admin1name?: string;
@ApiPropertyOptional({ description: 'Administrative level 2 name (county/district)' })
admin2name?: string;
}
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
export class StatisticsSearchDto extends createZodDto(StatisticsSearchSchema) {}
export class SmartSearchDto extends createZodDto(SmartSearchSchema) {}
export class SearchPlacesDto extends createZodDto(SearchPlacesSchema) {}
export class SearchPeopleDto extends createZodDto(SearchPeopleSchema) {}
export class PlacesResponseDto extends createZodDto(PlacesResponseSchema) {}
export class SearchSuggestionRequestDto extends createZodDto(SearchSuggestionRequestSchema) {}
export function mapPlaces(place: Place): PlacesResponseDto {
return {
@ -288,136 +163,68 @@ export function mapPlaces(place: Place): PlacesResponseDto {
};
}
export enum SearchSuggestionType {
COUNTRY = 'country',
STATE = 'state',
CITY = 'city',
CAMERA_MAKE = 'camera-make',
CAMERA_MODEL = 'camera-model',
CAMERA_LENS_MODEL = 'camera-lens-model',
}
export class SearchSuggestionRequestDto {
@ValidateEnum({ enum: SearchSuggestionType, name: 'SearchSuggestionType', description: 'Suggestion type' })
type!: SearchSuggestionType;
@ApiPropertyOptional({ description: 'Filter by country' })
@IsString()
@Optional()
country?: string;
@ApiPropertyOptional({ description: 'Filter by state/province' })
@IsString()
@Optional()
state?: string;
@ApiPropertyOptional({ description: 'Filter by camera make' })
@IsString()
@Optional()
make?: string;
@ApiPropertyOptional({ description: 'Filter by camera model' })
@IsString()
@Optional()
model?: string;
@ApiPropertyOptional({ description: 'Filter by lens model' })
@IsString()
@Optional()
lensModel?: string;
@ValidateBoolean({
optional: true,
description: 'Include null values in suggestions',
history: new HistoryBuilder().added('v1.111.0').stable('v2'),
const SearchFacetCountResponseSchema = z
.object({
count: z.int().min(0).describe('Number of assets with this facet value'),
value: z.string().describe('Facet value'),
})
includeNull?: boolean;
}
.meta({ id: 'SearchFacetCountResponseDto' });
class SearchFacetCountResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of assets with this facet value' })
count!: number;
@ApiProperty({ description: 'Facet value' })
value!: string;
}
const SearchFacetResponseSchema = z
.object({
fieldName: z.string().describe('Facet field name'),
counts: z.array(SearchFacetCountResponseSchema),
})
.meta({ id: 'SearchFacetResponseDto' });
class SearchFacetResponseDto {
@ApiProperty({ description: 'Facet field name' })
fieldName!: string;
@ApiProperty({ description: 'Facet counts' })
counts!: SearchFacetCountResponseDto[];
}
const SearchAlbumResponseSchema = z
.object({
total: z.int().min(0).describe('Total number of matching albums'),
count: z.int().min(0).describe('Number of albums in this page'),
items: z.array(AlbumResponseSchema),
facets: z.array(SearchFacetResponseSchema),
})
.meta({ id: 'SearchAlbumResponseDto' });
class SearchAlbumResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of matching albums' })
total!: number;
@ApiProperty({ type: 'integer', description: 'Number of albums in this page' })
count!: number;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
items!: AlbumResponseDto[];
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
facets!: SearchFacetResponseDto[];
}
const SearchAssetResponseSchema = z
.object({
total: z.int().min(0).describe('Total number of matching assets'),
count: z.int().min(0).describe('Number of assets in this page'),
items: z.array(AssetResponseSchema),
facets: z.array(SearchFacetResponseSchema),
nextPage: z.string().nullable().describe('Next page token'),
})
.meta({ id: 'SearchAssetResponseDto' });
class SearchAssetResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of matching assets' })
total!: number;
@ApiProperty({ type: 'integer', description: 'Number of assets in this page' })
count!: number;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
items!: AssetResponseDto[];
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
facets!: SearchFacetResponseDto[];
@ApiProperty({ description: 'Next page token' })
nextPage!: string | null;
}
const SearchResponseSchema = z
.object({
albums: SearchAlbumResponseSchema,
assets: SearchAssetResponseSchema,
})
.meta({ id: 'SearchResponseDto' });
export class SearchResponseDto {
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
albums!: SearchAlbumResponseDto;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
assets!: SearchAssetResponseDto;
}
export class SearchResponseDto extends createZodDto(SearchResponseSchema) {}
export class SearchStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of matching assets' })
total!: number;
}
const SearchStatisticsResponseSchema = z
.object({
total: z.int().describe('Total number of matching assets'),
})
.meta({ id: 'SearchStatisticsResponseDto' });
class SearchExploreItem {
@ApiProperty({ description: 'Explore value' })
value!: string;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
data!: AssetResponseDto;
}
export class SearchStatisticsResponseDto extends createZodDto(SearchStatisticsResponseSchema) {}
export class SearchExploreResponseDto {
@ApiProperty({ description: 'Explore field name' })
fieldName!: string;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
items!: SearchExploreItem[];
}
const SearchExploreItemSchema = z
.object({
value: z.string().describe('Explore value'),
data: AssetResponseSchema,
})
.meta({ id: 'SearchExploreItem' });
export class MemoryLaneDto {
@ApiProperty({ type: 'integer', description: 'Day of month' })
@IsInt()
@Type(() => Number)
@Max(31)
@Min(1)
day!: number;
const SearchExploreResponseSchema = z
.object({
fieldName: z.string().describe('Explore field name'),
items: z.array(SearchExploreItemSchema),
})
.meta({ id: 'SearchExploreResponseDto' });
@ApiProperty({ type: 'integer', description: 'Month' })
@IsInt()
@Type(() => Number)
@Max(12)
@Min(1)
month!: number;
}
export class SearchExploreResponseDto extends createZodDto(SearchExploreResponseSchema) {}

View File

@ -1,242 +1,169 @@
import { ApiProperty, ApiPropertyOptional, ApiResponseProperty } from '@nestjs/swagger';
import { SemVer } from 'semver';
import { SystemConfigThemeDto } from 'src/dtos/system-config.dto';
import { createZodDto } from 'nestjs-zod';
import type { SemVer } from 'semver';
import { isoDatetimeToDate } from 'src/validation';
import * as z from 'zod';
export class ServerPingResponse {
@ApiResponseProperty({ type: String, example: 'pong' })
res!: string;
}
const ServerPingResponseSchema = z
.object({
res: z.string().meta({ example: 'pong' }),
})
.meta({ id: 'ServerPingResponse' });
export class ServerAboutResponseDto {
@ApiProperty({ description: 'Server version' })
version!: string;
@ApiProperty({ description: 'URL to version information' })
versionUrl!: string;
const ServerAboutResponseSchema = z
.object({
version: z.string().describe('Server version'),
versionUrl: z.string().describe('URL to version information'),
repository: z.string().optional().describe('Repository name'),
repositoryUrl: z.string().optional().describe('Repository URL'),
sourceRef: z.string().optional().describe('Source reference (branch/tag)'),
sourceCommit: z.string().optional().describe('Source commit hash'),
sourceUrl: z.string().optional().describe('Source URL'),
build: z.string().optional().describe('Build identifier'),
buildUrl: z.string().optional().describe('Build URL'),
buildImage: z.string().optional().describe('Build image name'),
buildImageUrl: z.string().optional().describe('Build image URL'),
nodejs: z.string().optional().describe('Node.js version'),
ffmpeg: z.string().optional().describe('FFmpeg version'),
imagemagick: z.string().optional().describe('ImageMagick version'),
libvips: z.string().optional().describe('libvips version'),
exiftool: z.string().optional().describe('ExifTool version'),
licensed: z.boolean().describe('Whether the server is licensed'),
thirdPartySourceUrl: z.string().optional().describe('Third-party source URL'),
thirdPartyBugFeatureUrl: z.string().optional().describe('Third-party bug/feature URL'),
thirdPartyDocumentationUrl: z.string().optional().describe('Third-party documentation URL'),
thirdPartySupportUrl: z.string().optional().describe('Third-party support URL'),
})
.meta({ id: 'ServerAboutResponseDto' });
@ApiPropertyOptional({ description: 'Repository name' })
repository?: string;
@ApiPropertyOptional({ description: 'Repository URL' })
repositoryUrl?: string;
const ServerApkLinksSchema = z
.object({
arm64v8a: z.string().describe('APK download link for ARM64 v8a architecture'),
armeabiv7a: z.string().describe('APK download link for ARM EABI v7a architecture'),
universal: z.string().describe('APK download link for universal architecture'),
x86_64: z.string().describe('APK download link for x86_64 architecture'),
})
.meta({ id: 'ServerApkLinksDto' });
@ApiPropertyOptional({ description: 'Source reference (branch/tag)' })
sourceRef?: string;
@ApiPropertyOptional({ description: 'Source commit hash' })
sourceCommit?: string;
@ApiPropertyOptional({ description: 'Source URL' })
sourceUrl?: string;
const ServerStorageResponseSchema = z
.object({
diskSize: z.string().describe('Total disk size (human-readable format)'),
diskUse: z.string().describe('Used disk space (human-readable format)'),
diskAvailable: z.string().describe('Available disk space (human-readable format)'),
diskSizeRaw: z.int().describe('Total disk size in bytes'),
diskUseRaw: z.int().describe('Used disk space in bytes'),
diskAvailableRaw: z.int().describe('Available disk space in bytes'),
diskUsagePercentage: z.number().meta({ format: 'double' }).describe('Disk usage percentage (0-100)'),
})
.meta({ id: 'ServerStorageResponseDto' });
@ApiPropertyOptional({ description: 'Build identifier' })
build?: string;
@ApiPropertyOptional({ description: 'Build URL' })
buildUrl?: string;
@ApiPropertyOptional({ description: 'Build image name' })
buildImage?: string;
@ApiPropertyOptional({ description: 'Build image URL' })
buildImageUrl?: string;
const ServerVersionResponseSchema = z
.object({
major: z.int().describe('Major version number'),
minor: z.int().describe('Minor version number'),
patch: z.int().describe('Patch version number'),
})
.meta({ id: 'ServerVersionResponseDto' });
@ApiPropertyOptional({ description: 'Node.js version' })
nodejs?: string;
@ApiPropertyOptional({ description: 'FFmpeg version' })
ffmpeg?: string;
@ApiPropertyOptional({ description: 'ImageMagick version' })
imagemagick?: string;
@ApiPropertyOptional({ description: 'libvips version' })
libvips?: string;
@ApiPropertyOptional({ description: 'ExifTool version' })
exiftool?: string;
const ServerVersionHistoryResponseSchema = z
.object({
id: z.string().describe('Version history entry ID'),
createdAt: isoDatetimeToDate.describe('When this version was first seen'),
version: z.string().describe('Version string'),
})
.meta({ id: 'ServerVersionHistoryResponseDto' });
@ApiProperty({ description: 'Whether the server is licensed' })
licensed!: boolean;
const UsageByUserSchema = z
.object({
userId: z.string().describe('User ID'),
userName: z.string().describe('User name'),
photos: z.int().describe('Number of photos'),
videos: z.int().describe('Number of videos'),
usage: z.int().describe('Total storage usage in bytes'),
usagePhotos: z.int().describe('Storage usage for photos in bytes'),
usageVideos: z.int().describe('Storage usage for videos in bytes'),
quotaSizeInBytes: z.int().nullable().describe('User quota size in bytes (null if unlimited)'),
})
.meta({ id: 'UsageByUserDto' });
@ApiPropertyOptional({ description: 'Third-party source URL' })
thirdPartySourceUrl?: string;
@ApiPropertyOptional({ description: 'Third-party bug/feature URL' })
thirdPartyBugFeatureUrl?: string;
@ApiPropertyOptional({ description: 'Third-party documentation URL' })
thirdPartyDocumentationUrl?: string;
@ApiPropertyOptional({ description: 'Third-party support URL' })
thirdPartySupportUrl?: string;
}
const ServerStatsResponseSchema = z
.object({
photos: z.int().describe('Total number of photos'),
videos: z.int().describe('Total number of videos'),
usage: z.int().describe('Total storage usage in bytes'),
usagePhotos: z.int().describe('Storage usage for photos in bytes'),
usageVideos: z.int().describe('Storage usage for videos in bytes'),
usageByUser: z.array(UsageByUserSchema).describe('Array of usage for each user'),
})
.meta({ id: 'ServerStatsResponseDto' });
export class ServerApkLinksDto {
@ApiProperty({ description: 'APK download link for ARM64 v8a architecture' })
arm64v8a!: string;
@ApiProperty({ description: 'APK download link for ARM EABI v7a architecture' })
armeabiv7a!: string;
@ApiProperty({ description: 'APK download link for universal architecture' })
universal!: string;
@ApiProperty({ description: 'APK download link for x86_64 architecture' })
x86_64!: string;
}
const ServerMediaTypesResponseSchema = z
.object({
video: z.array(z.string()).describe('Supported video MIME types'),
image: z.array(z.string()).describe('Supported image MIME types'),
sidecar: z.array(z.string()).describe('Supported sidecar MIME types'),
})
.meta({ id: 'ServerMediaTypesResponseDto' });
export class ServerStorageResponseDto {
@ApiProperty({ description: 'Total disk size (human-readable format)' })
diskSize!: string;
@ApiProperty({ description: 'Used disk space (human-readable format)' })
diskUse!: string;
@ApiProperty({ description: 'Available disk space (human-readable format)' })
diskAvailable!: string;
const ServerThemeSchema = z
.object({
customCss: z.string().describe('Custom CSS for theming'),
})
.meta({ id: 'ServerThemeDto' });
@ApiProperty({ type: 'integer', format: 'int64', description: 'Total disk size in bytes' })
diskSizeRaw!: number;
const ServerConfigSchema = z
.object({
oauthButtonText: z.string().describe('OAuth button text'),
loginPageMessage: z.string().describe('Login page message'),
trashDays: z.int().describe('Number of days before trashed assets are permanently deleted'),
userDeleteDelay: z.int().describe('Delay in days before deleted users are permanently removed'),
isInitialized: z.boolean().describe('Whether the server has been initialized'),
isOnboarded: z.boolean().describe('Whether the admin has completed onboarding'),
externalDomain: z.string().describe('External domain URL'),
publicUsers: z.boolean().describe('Whether public user registration is enabled'),
mapDarkStyleUrl: z.string().describe('Map dark style URL'),
mapLightStyleUrl: z.string().describe('Map light style URL'),
maintenanceMode: z.boolean().describe('Whether maintenance mode is active'),
})
.meta({ id: 'ServerConfigDto' });
@ApiProperty({ type: 'integer', format: 'int64', description: 'Used disk space in bytes' })
diskUseRaw!: number;
const ServerFeaturesSchema = z
.object({
smartSearch: z.boolean().describe('Whether smart search is enabled'),
duplicateDetection: z.boolean().describe('Whether duplicate detection is enabled'),
configFile: z.boolean().describe('Whether config file is available'),
facialRecognition: z.boolean().describe('Whether facial recognition is enabled'),
map: z.boolean().describe('Whether map feature is enabled'),
trash: z.boolean().describe('Whether trash feature is enabled'),
reverseGeocoding: z.boolean().describe('Whether reverse geocoding is enabled'),
importFaces: z.boolean().describe('Whether face import is enabled'),
oauth: z.boolean().describe('Whether OAuth is enabled'),
oauthAutoLaunch: z.boolean().describe('Whether OAuth auto-launch is enabled'),
passwordLogin: z.boolean().describe('Whether password login is enabled'),
sidecar: z.boolean().describe('Whether sidecar files are supported'),
search: z.boolean().describe('Whether search is enabled'),
email: z.boolean().describe('Whether email notifications are enabled'),
ocr: z.boolean().describe('Whether OCR is enabled'),
})
.meta({ id: 'ServerFeaturesDto' });
@ApiProperty({ type: 'integer', format: 'int64', description: 'Available disk space in bytes' })
diskAvailableRaw!: number;
export class ServerPingResponse extends createZodDto(ServerPingResponseSchema) {}
export class ServerAboutResponseDto extends createZodDto(ServerAboutResponseSchema) {}
export class ServerApkLinksDto extends createZodDto(ServerApkLinksSchema) {}
export class ServerStorageResponseDto extends createZodDto(ServerStorageResponseSchema) {}
@ApiProperty({ type: 'number', format: 'double', description: 'Disk usage percentage (0-100)' })
diskUsagePercentage!: number;
}
export class ServerVersionResponseDto {
@ApiProperty({ type: 'integer', description: 'Major version number' })
major!: number;
@ApiProperty({ type: 'integer', description: 'Minor version number' })
minor!: number;
@ApiProperty({ type: 'integer', description: 'Patch version number' })
patch!: number;
static fromSemVer(value: SemVer) {
export class ServerVersionResponseDto extends createZodDto(ServerVersionResponseSchema) {
static fromSemVer(value: SemVer): z.infer<typeof ServerVersionResponseSchema> {
return { major: value.major, minor: value.minor, patch: value.patch };
}
}
export class ServerVersionHistoryResponseDto {
@ApiProperty({ description: 'Version history entry ID' })
id!: string;
@ApiProperty({ description: 'When this version was first seen', format: 'date-time' })
createdAt!: Date;
@ApiProperty({ description: 'Version string' })
version!: string;
}
export class UsageByUserDto {
@ApiProperty({ type: 'string', description: 'User ID' })
userId!: string;
@ApiProperty({ type: 'string', description: 'User name' })
userName!: string;
@ApiProperty({ type: 'integer', description: 'Number of photos' })
photos!: number;
@ApiProperty({ type: 'integer', description: 'Number of videos' })
videos!: number;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Total storage usage in bytes' })
usage!: number;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for photos in bytes' })
usagePhotos!: number;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for videos in bytes' })
usageVideos!: number;
@ApiProperty({
type: 'integer',
format: 'int64',
nullable: true,
description: 'User quota size in bytes (null if unlimited)',
})
quotaSizeInBytes!: number | null;
}
export class ServerStatsResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of photos' })
photos = 0;
@ApiProperty({ type: 'integer', description: 'Total number of videos' })
videos = 0;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Total storage usage in bytes' })
usage = 0;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for photos in bytes' })
usagePhotos = 0;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for videos in bytes' })
usageVideos = 0;
@ApiProperty({
isArray: true,
type: UsageByUserDto,
title: 'Array of usage for each user',
example: [
{
photos: 1,
videos: 1,
diskUsageRaw: 2,
usagePhotos: 1,
usageVideos: 1,
},
],
})
usageByUser: UsageByUserDto[] = [];
}
export class ServerMediaTypesResponseDto {
@ApiProperty({ description: 'Supported video MIME types' })
video!: string[];
@ApiProperty({ description: 'Supported image MIME types' })
image!: string[];
@ApiProperty({ description: 'Supported sidecar MIME types' })
sidecar!: string[];
}
export class ServerThemeDto extends SystemConfigThemeDto {}
export class ServerConfigDto {
@ApiProperty({ description: 'OAuth button text' })
oauthButtonText!: string;
@ApiProperty({ description: 'Login page message' })
loginPageMessage!: string;
@ApiProperty({ type: 'integer', description: 'Number of days before trashed assets are permanently deleted' })
trashDays!: number;
@ApiProperty({ type: 'integer', description: 'Delay in days before deleted users are permanently removed' })
userDeleteDelay!: number;
@ApiProperty({ description: 'Whether the server has been initialized' })
isInitialized!: boolean;
@ApiProperty({ description: 'Whether the admin has completed onboarding' })
isOnboarded!: boolean;
@ApiProperty({ description: 'External domain URL' })
externalDomain!: string;
@ApiProperty({ description: 'Whether public user registration is enabled' })
publicUsers!: boolean;
@ApiProperty({ description: 'Map dark style URL' })
mapDarkStyleUrl!: string;
@ApiProperty({ description: 'Map light style URL' })
mapLightStyleUrl!: string;
@ApiProperty({ description: 'Whether maintenance mode is active' })
maintenanceMode!: boolean;
}
export class ServerFeaturesDto {
@ApiProperty({ description: 'Whether smart search is enabled' })
smartSearch!: boolean;
@ApiProperty({ description: 'Whether duplicate detection is enabled' })
duplicateDetection!: boolean;
@ApiProperty({ description: 'Whether config file is available' })
configFile!: boolean;
@ApiProperty({ description: 'Whether facial recognition is enabled' })
facialRecognition!: boolean;
@ApiProperty({ description: 'Whether map feature is enabled' })
map!: boolean;
@ApiProperty({ description: 'Whether trash feature is enabled' })
trash!: boolean;
@ApiProperty({ description: 'Whether reverse geocoding is enabled' })
reverseGeocoding!: boolean;
@ApiProperty({ description: 'Whether face import is enabled' })
importFaces!: boolean;
@ApiProperty({ description: 'Whether OAuth is enabled' })
oauth!: boolean;
@ApiProperty({ description: 'Whether OAuth auto-launch is enabled' })
oauthAutoLaunch!: boolean;
@ApiProperty({ description: 'Whether password login is enabled' })
passwordLogin!: boolean;
@ApiProperty({ description: 'Whether sidecar files are supported' })
sidecar!: boolean;
@ApiProperty({ description: 'Whether search is enabled' })
search!: boolean;
@ApiProperty({ description: 'Whether email notifications are enabled' })
email!: boolean;
@ApiProperty({ description: 'Whether OCR is enabled' })
ocr!: boolean;
}
export class ServerVersionHistoryResponseDto extends createZodDto(ServerVersionHistoryResponseSchema) {}
export class UsageByUserDto extends createZodDto(UsageByUserSchema) {}
export class ServerStatsResponseDto extends createZodDto(ServerStatsResponseSchema) {}
export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesResponseSchema) {}
export class ServerThemeDto extends createZodDto(ServerThemeSchema) {}
export class ServerConfigDto extends createZodDto(ServerConfigSchema) {}
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
export interface ReleaseNotification {
isAvailable: boolean;

View File

@ -1,57 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Equals, IsInt, IsPositive, IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Session } from 'src/database';
import { Optional, ValidateBoolean } from 'src/validation';
import * as z from 'zod';
export class SessionCreateDto {
@ApiPropertyOptional({ type: 'number', description: 'Session duration in seconds' })
@IsInt()
@IsPositive()
@Optional()
duration?: number;
const SessionCreateSchema = z
.object({
duration: z.number().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'),
})
.meta({ id: 'SessionCreateDto' });
@ApiPropertyOptional({ description: 'Device type' })
@IsString()
@Optional()
deviceType?: string;
const SessionUpdateSchema = z
.object({
isPendingSyncReset: z.boolean().optional().describe('Reset pending sync state'),
})
.meta({ id: 'SessionUpdateDto' });
@ApiPropertyOptional({ description: 'Device OS' })
@IsString()
@Optional()
deviceOS?: string;
}
const SessionResponseSchema = z
.object({
id: z.string().describe('Session ID'),
createdAt: z.string().describe('Creation date'),
updatedAt: z.string().describe('Last update date'),
expiresAt: z.string().optional().describe('Expiration date'),
current: z.boolean().describe('Is current session'),
deviceType: z.string().describe('Device type'),
deviceOS: z.string().describe('Device OS'),
appVersion: z.string().nullable().describe('App version'),
isPendingSyncReset: z.boolean().describe('Is pending sync reset'),
})
.meta({ id: 'SessionResponseDto' });
export class SessionUpdateDto {
@ValidateBoolean({ optional: true, description: 'Reset pending sync state' })
@Equals(true)
isPendingSyncReset?: true;
}
const SessionCreateResponseSchema = SessionResponseSchema.extend({
token: z.string().describe('Session token'),
}).meta({ id: 'SessionCreateResponseDto' });
export class SessionResponseDto {
@ApiProperty({ description: 'Session ID' })
id!: string;
@ApiProperty({ description: 'Creation date' })
createdAt!: string;
@ApiProperty({ description: 'Last update date' })
updatedAt!: string;
@ApiPropertyOptional({ description: 'Expiration date' })
expiresAt?: string;
@ApiProperty({ description: 'Is current session' })
current!: boolean;
@ApiProperty({ description: 'Device type' })
deviceType!: string;
@ApiProperty({ description: 'Device OS' })
deviceOS!: string;
@ApiProperty({ description: 'App version' })
appVersion!: string | null;
@ApiProperty({ description: 'Is pending sync reset' })
isPendingSyncReset!: boolean;
}
export class SessionCreateResponseDto extends SessionResponseDto {
@ApiProperty({ description: 'Session token' })
token!: string;
}
export class SessionCreateDto extends createZodDto(SessionCreateSchema) {}
export class SessionUpdateDto extends createZodDto(SessionUpdateSchema) {}
export class SessionResponseDto extends createZodDto(SessionResponseSchema) {}
export class SessionCreateResponseDto extends createZodDto(SessionCreateResponseSchema) {}
export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id,

View File

@ -1,155 +1,103 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { SharedLink } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseSchema, mapAlbumWithoutAssets } 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 * as z from 'zod';
export class SharedLinkSearchDto {
@ValidateUUID({ optional: true, description: 'Filter by album ID' })
albumId?: string;
@ValidateUUID({
optional: true,
description: 'Filter by shared link ID',
history: new HistoryBuilder().added('v2.5.0'),
const SharedLinkSearchSchema = z
.object({
albumId: z.uuidv4().optional().describe('Filter by album ID'),
id: z
.uuidv4()
.optional()
.describe('Filter by shared link ID')
.meta({ ...new HistoryBuilder().added('v2.5.0').getExtensions() }),
})
id?: string;
}
.meta({ id: 'SharedLinkSearchDto' });
export class SharedLinkCreateDto {
@ValidateEnum({ enum: SharedLinkType, name: 'SharedLinkType', description: 'Shared link type' })
type!: SharedLinkType;
@ValidateUUID({ each: true, optional: true, description: 'Asset IDs (for individual assets)' })
assetIds?: string[];
@ValidateUUID({ optional: true, description: 'Album ID (for album sharing)' })
albumId?: string;
@ApiPropertyOptional({ description: 'Link description' })
@Optional({ nullable: true, emptyToNull: true })
@IsString()
description?: string | null;
@ApiPropertyOptional({ description: 'Link password' })
@Optional({ nullable: true, emptyToNull: true })
@IsString()
password?: string | null;
@ApiPropertyOptional({ description: 'Custom URL slug' })
@Optional({ nullable: true, emptyToNull: true })
@IsString()
slug?: string | null;
@ValidateDate({ optional: true, nullable: true, description: 'Expiration date' })
expiresAt?: Date | null = null;
@ValidateBoolean({ optional: true, description: 'Allow uploads' })
allowUpload?: boolean;
@ValidateBoolean({ optional: true, description: 'Allow downloads', default: true })
allowDownload?: boolean = true;
@ValidateBoolean({ optional: true, description: 'Show metadata', default: true })
showMetadata?: boolean = true;
}
export class SharedLinkEditDto {
@ApiPropertyOptional({ description: 'Link description' })
@Optional({ nullable: true, emptyToNull: true })
@IsString()
description?: string | null;
@ApiPropertyOptional({ description: 'Link password' })
@Optional({ nullable: true, emptyToNull: true })
@IsString()
password?: string | null;
@ApiPropertyOptional({ description: 'Custom URL slug' })
@Optional({ nullable: true, emptyToNull: true })
@IsString()
slug?: string | null;
@ApiPropertyOptional({ description: 'Expiration date' })
@Optional({ nullable: true })
expiresAt?: Date | null;
@ValidateBoolean({ optional: true, description: 'Allow uploads' })
allowUpload?: boolean;
@ValidateBoolean({ optional: true, description: 'Allow downloads' })
allowDownload?: boolean;
@ValidateBoolean({ optional: true, description: 'Show metadata' })
showMetadata?: boolean;
@ValidateBoolean({
optional: true,
description:
'Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.',
const SharedLinkCreateSchema = z
.object({
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'),
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'),
showMetadata: z.boolean().default(true).optional().describe('Show metadata'),
})
changeExpiryTime?: boolean;
}
.meta({ id: 'SharedLinkCreateDto' });
export class SharedLinkLoginDto {
@ValidateString({ description: 'Shared link password', example: 'password' })
password!: string;
}
export class SharedLinkPasswordDto {
@ApiPropertyOptional({ example: 'password', description: 'Link password' })
@IsString()
@Optional()
password?: string;
@ApiPropertyOptional({ description: 'Access token' })
@IsString()
@Optional()
token?: string;
}
export class SharedLinkResponseDto {
@ApiProperty({ description: 'Shared link ID' })
id!: string;
@ApiProperty({ description: 'Link description' })
description!: string | null;
@ApiProperty({ description: 'Has password' })
password!: string | null;
@Property({
description: 'Access token',
history: new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0'),
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'),
expiresAt: isoDatetimeToDate.nullish().describe('Expiration date'),
allowUpload: z.boolean().optional().describe('Allow uploads'),
allowDownload: z.boolean().optional().describe('Allow downloads'),
showMetadata: z.boolean().optional().describe('Show metadata'),
changeExpiryTime: z
.boolean()
.optional()
.describe(
'Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.',
),
})
token?: string | null;
@ApiProperty({ description: 'Owner user ID' })
userId!: string;
@ApiProperty({ description: 'Encryption key (base64url)' })
key!: string;
.meta({ id: 'SharedLinkEditDto' });
@ValidateEnum({ enum: SharedLinkType, name: 'SharedLinkType', description: 'Shared link type' })
type!: SharedLinkType;
@ApiProperty({ description: 'Creation date' })
createdAt!: Date;
@ApiProperty({ description: 'Expiration date' })
expiresAt!: Date | null;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
assets!: AssetResponseDto[];
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
album?: AlbumResponseDto;
@ApiProperty({ description: 'Allow uploads' })
allowUpload!: boolean;
const SharedLinkLoginSchema = z
.object({
password: z.string().describe('Shared link password').meta({ example: 'password' }),
})
.meta({ id: 'SharedLinkLoginDto' });
@ApiProperty({ description: 'Allow downloads' })
allowDownload!: boolean;
@ApiProperty({ description: 'Show metadata' })
showMetadata!: boolean;
const SharedLinkPasswordSchema = z
.object({
password: z.string().optional().describe('Link password'),
token: z.string().optional().describe('Access token'),
})
.meta({ id: 'SharedLinkPasswordDto' });
@ApiProperty({ description: 'Custom URL slug' })
slug!: string | null;
}
const SharedLinkResponseSchema = z
.object({
id: z.string().describe('Shared link ID'),
description: z.string().nullable().describe('Link description'),
password: z.string().nullable().describe('Has password'),
token: z
.string()
.nullish()
.describe('Access token')
.meta({
...new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0').getExtensions(),
deprecated: true,
}),
userId: z.string().describe('Owner user ID'),
key: z.string().describe('Encryption key (base64url)'),
type: SharedLinkTypeSchema,
createdAt: isoDatetimeToDate.describe('Creation date'),
expiresAt: isoDatetimeToDate.nullable().describe('Expiration date'),
assets: z.array(AssetResponseSchema),
album: AlbumResponseSchema.optional(),
allowUpload: z.boolean().describe('Allow uploads'),
allowDownload: z.boolean().describe('Allow downloads'),
showMetadata: z.boolean().describe('Show metadata'),
slug: z.string().nullable().describe('Custom URL slug'),
})
.describe('Shared link response')
.meta({ id: 'SharedLinkResponseDto' });
export class SharedLinkSearchDto extends createZodDto(SharedLinkSearchSchema) {}
export class SharedLinkCreateDto extends createZodDto(SharedLinkCreateSchema) {}
export class SharedLinkEditDto extends createZodDto(SharedLinkEditSchema) {}
export class SharedLinkLoginDto extends createZodDto(SharedLinkLoginSchema) {}
export class SharedLinkPasswordDto extends createZodDto(SharedLinkPasswordSchema) {}
export class SharedLinkResponseDto extends createZodDto(SharedLinkResponseSchema) {}
export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto {
const assets = sharedLink.assets || [];

View File

@ -1,34 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Stack } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ValidateUUID } from 'src/validation';
import * as z from 'zod';
export class StackCreateDto {
@ValidateUUID({ each: true, description: 'Asset IDs (first becomes primary, min 2)' })
@ArrayMinSize(2)
assetIds!: string[];
}
const StackSearchSchema = z
.object({
primaryAssetId: z.uuidv4().optional().describe('Filter by primary asset ID'),
})
.meta({ id: 'StackSearchDto' });
export class StackSearchDto {
@ValidateUUID({ optional: true, description: 'Filter by primary asset ID' })
primaryAssetId?: string;
}
const StackCreateSchema = z
.object({
assetIds: z.array(z.uuidv4()).min(2).describe('Asset IDs (first becomes primary, min 2)'),
})
.meta({ id: 'StackCreateDto' });
export class StackUpdateDto {
@ValidateUUID({ optional: true, description: 'Primary asset ID' })
primaryAssetId?: string;
}
const StackUpdateSchema = z
.object({
primaryAssetId: z.uuidv4().optional().describe('Primary asset ID'),
})
.meta({ id: 'StackUpdateDto' });
export class StackResponseDto {
@ApiProperty({ description: 'Stack ID' })
id!: string;
@ApiProperty({ description: 'Primary asset ID' })
primaryAssetId!: string;
@ApiProperty({ description: 'Stack assets' })
assets!: AssetResponseDto[];
}
const StackResponseSchema = z
.object({
id: z.string().describe('Stack ID'),
primaryAssetId: z.string().describe('Primary asset ID'),
assets: z.array(AssetResponseSchema),
})
.describe('Stack response')
.meta({ id: 'StackResponseDto' });
export class StackSearchDto extends createZodDto(StackSearchSchema) {}
export class StackCreateDto extends createZodDto(StackCreateSchema) {}
export class StackUpdateDto extends createZodDto(StackUpdateSchema) {}
export class StackResponseDto extends createZodDto(StackResponseSchema) {}
export const mapStack = (stack: Stack, { auth }: { auth?: AuthDto }) => {
const primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId);

View File

@ -1,492 +1,423 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { createZodDto } from 'nestjs-zod';
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
AssetOrder,
AssetType,
AssetVisibility,
MemoryType,
AlbumUserRoleSchema,
AssetOrderSchema,
AssetTypeSchema,
AssetVisibilitySchema,
MemoryTypeSchema,
SyncEntityType,
SyncRequestType,
UserAvatarColor,
UserMetadataKey,
SyncEntityTypeSchema,
SyncRequestTypeSchema,
UserAvatarColorSchema,
UserMetadataKeySchema,
} from 'src/enum';
import { UserMetadata } from 'src/types';
import { ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
import { isoDatetimeToDate } from 'src/validation';
import * as z from 'zod';
export class AssetFullSyncDto {
@ValidateUUID({ optional: true, description: 'Last asset ID (pagination)' })
lastId?: string;
const AssetFullSyncSchema = z
.object({
lastId: z.uuidv4().optional().describe('Last asset ID (pagination)'),
updatedUntil: isoDatetimeToDate.describe('Sync assets updated until this date'),
limit: z.int().min(1).describe('Maximum number of assets to return'),
userId: z.uuidv4().optional().describe('Filter by user ID'),
})
.meta({ id: 'AssetFullSyncDto' });
@ValidateDate({ description: 'Sync assets updated until this date' })
updatedUntil!: Date;
const AssetDeltaSyncSchema = z
.object({
updatedAfter: isoDatetimeToDate.describe('Sync assets updated after this date'),
userIds: z.array(z.uuidv4()).describe('User IDs to sync'),
})
.meta({ id: 'AssetDeltaSyncDto' });
@ApiProperty({ type: 'integer', description: 'Maximum number of assets to return' })
@IsInt()
@IsPositive()
limit!: number;
export class AssetFullSyncDto extends createZodDto(AssetFullSyncSchema) {}
export class AssetDeltaSyncDto extends createZodDto(AssetDeltaSyncSchema) {}
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
userId?: string;
}
const AssetDeltaSyncResponseSchema = z
.object({
needsFullSync: z.boolean().describe('Whether full sync is needed'),
upserted: z.array(AssetResponseSchema),
deleted: z.array(z.string()).describe('Deleted asset IDs'),
})
.describe('Asset delta sync response')
.meta({ id: 'AssetDeltaSyncResponseDto' });
export class AssetDeltaSyncDto {
@ValidateDate({ description: 'Sync assets updated after this date' })
updatedAfter!: Date;
@ValidateUUID({ each: true, description: 'User IDs to sync' })
userIds!: string[];
}
export class AssetDeltaSyncResponseDto {
@ApiProperty({ description: 'Whether full sync is needed' })
needsFullSync!: boolean;
@ApiProperty({ description: 'Upserted assets' })
upserted!: AssetResponseDto[];
@ApiProperty({ description: 'Deleted asset IDs' })
deleted!: string[];
}
export class AssetDeltaSyncResponseDto extends createZodDto(AssetDeltaSyncResponseSchema) {}
export const extraSyncModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping
return (object: Function) => {
extraSyncModels.push(object);
};
};
@ExtraModel()
export class SyncUserV1 {
@ApiProperty({ description: 'User ID' })
id!: string;
@ApiProperty({ description: 'User name' })
name!: string;
@ApiProperty({ description: 'User email' })
email!: string;
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'User avatar color' })
avatarColor!: UserAvatarColor | null;
@ApiProperty({ description: 'User deleted at' })
deletedAt!: Date | null;
@ApiProperty({ description: 'User has profile image' })
hasProfileImage!: boolean;
@ApiProperty({ description: 'User profile changed at' })
profileChangedAt!: Date;
}
const SyncUserV1Schema = z
.object({
id: z.string().describe('User ID'),
name: z.string().describe('User name'),
email: z.string().describe('User email'),
avatarColor: UserAvatarColorSchema.nullish(),
deletedAt: isoDatetimeToDate.nullable().describe('User deleted at'),
hasProfileImage: z.boolean().describe('User has profile image'),
profileChangedAt: isoDatetimeToDate.describe('User profile changed at'),
})
.meta({ id: 'SyncUserV1' });
const SyncAuthUserV1Schema = SyncUserV1Schema.merge(
z.object({
isAdmin: z.boolean().describe('User is admin'),
pinCode: z.string().nullable().describe('User pin code'),
oauthId: z.string().describe('User OAuth ID'),
storageLabel: z.string().nullable().describe('User storage label'),
quotaSizeInBytes: z.int().nullable().describe('Quota size in bytes'),
quotaUsageInBytes: z.int().describe('Quota usage in bytes'),
}),
).meta({ id: 'SyncAuthUserV1' });
const SyncUserDeleteV1Schema = z.object({ userId: z.string().describe('User ID') }).meta({ id: 'SyncUserDeleteV1' });
const SyncPartnerV1Schema = z
.object({
sharedById: z.string().describe('Shared by ID'),
sharedWithId: z.string().describe('Shared with ID'),
inTimeline: z.boolean().describe('In timeline'),
})
.meta({ id: 'SyncPartnerV1' });
const SyncPartnerDeleteV1Schema = z
.object({
sharedById: z.string().describe('Shared by ID'),
sharedWithId: z.string().describe('Shared with ID'),
})
.meta({ id: 'SyncPartnerDeleteV1' });
const SyncAssetV1Schema = z
.object({
id: z.string().describe('Asset ID'),
ownerId: z.string().describe('Owner ID'),
originalFileName: z.string().describe('Original file name'),
thumbhash: z.string().nullable().describe('Thumbhash'),
checksum: z.string().describe('Checksum'),
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
duration: z.string().nullable().describe('Duration'),
type: AssetTypeSchema,
deletedAt: isoDatetimeToDate.nullable().describe('Deleted at'),
isFavorite: z.boolean().describe('Is favorite'),
visibility: AssetVisibilitySchema,
livePhotoVideoId: z.string().nullable().describe('Live photo video ID'),
stackId: z.string().nullable().describe('Stack ID'),
libraryId: z.string().nullable().describe('Library ID'),
width: z.int().nullable().describe('Asset width'),
height: z.int().nullable().describe('Asset height'),
isEdited: z.boolean().describe('Is edited'),
})
.meta({ id: 'SyncAssetV1' });
@ExtraModel()
export class SyncAuthUserV1 extends SyncUserV1 {
@ApiProperty({ description: 'User is admin' })
isAdmin!: boolean;
@ApiProperty({ description: 'User pin code' })
pinCode!: string | null;
@ApiProperty({ description: 'User OAuth ID' })
oauthId!: string;
@ApiProperty({ description: 'User storage label' })
storageLabel!: string | null;
@ApiProperty({ type: 'integer' })
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer' })
quotaUsageInBytes!: number;
}
class SyncUserV1 extends createZodDto(SyncUserV1Schema) {}
@ExtraModel()
class SyncAuthUserV1 extends createZodDto(SyncAuthUserV1Schema) {}
@ExtraModel()
class SyncUserDeleteV1 extends createZodDto(SyncUserDeleteV1Schema) {}
@ExtraModel()
class SyncPartnerV1 extends createZodDto(SyncPartnerV1Schema) {}
@ExtraModel()
class SyncPartnerDeleteV1 extends createZodDto(SyncPartnerDeleteV1Schema) {}
@ExtraModel()
export class SyncAssetV1 extends createZodDto(SyncAssetV1Schema) {}
const SyncAssetDeleteV1Schema = z
.object({ assetId: z.string().describe('Asset ID') })
.meta({ id: 'SyncAssetDeleteV1' });
const SyncAssetExifV1Schema = z
.object({
assetId: z.string().describe('Asset ID'),
description: z.string().nullable().describe('Description'),
exifImageWidth: z.int().nullable().describe('Exif image width'),
exifImageHeight: z.int().nullable().describe('Exif image height'),
fileSizeInByte: z.int().nullable().describe('File size in byte'),
orientation: z.string().nullable().describe('Orientation'),
dateTimeOriginal: isoDatetimeToDate.nullable().describe('Date time original'),
modifyDate: isoDatetimeToDate.nullable().describe('Modify date'),
timeZone: z.string().nullable().describe('Time zone'),
latitude: z.number().meta({ format: 'double' }).nullable().describe('Latitude'),
longitude: z.number().meta({ format: 'double' }).nullable().describe('Longitude'),
projectionType: z.string().nullable().describe('Projection type'),
city: z.string().nullable().describe('City'),
state: z.string().nullable().describe('State'),
country: z.string().nullable().describe('Country'),
make: z.string().nullable().describe('Make'),
model: z.string().nullable().describe('Model'),
lensModel: z.string().nullable().describe('Lens model'),
fNumber: z.number().meta({ format: 'double' }).nullable().describe('F number'),
focalLength: z.number().meta({ format: 'double' }).nullable().describe('Focal length'),
iso: z.int().nullable().describe('ISO'),
exposureTime: z.string().nullable().describe('Exposure time'),
profileDescription: z.string().nullable().describe('Profile description'),
rating: z.int().nullable().describe('Rating'),
fps: z.number().meta({ format: 'double' }).nullable().describe('FPS'),
})
.meta({ id: 'SyncAssetExifV1' });
const SyncAssetMetadataV1Schema = z
.object({
assetId: z.string().describe('Asset ID'),
key: z.string().describe('Key'),
value: z.record(z.string(), z.unknown()).describe('Value'),
})
.meta({ id: 'SyncAssetMetadataV1' });
const SyncAssetMetadataDeleteV1Schema = z
.object({
assetId: z.string().describe('Asset ID'),
key: z.string().describe('Key'),
})
.meta({ id: 'SyncAssetMetadataDeleteV1' });
const SyncAssetEditV1Schema = z
.object({
id: z.string().describe('Edit ID'),
assetId: z.string().describe('Asset ID'),
action: AssetEditActionSchema,
parameters: z.record(z.string(), z.unknown()).describe('Edit parameters'),
sequence: z.int().describe('Edit sequence'),
})
.meta({ id: 'SyncAssetEditV1' });
const SyncAssetEditDeleteV1Schema = z
.object({ editId: z.string().describe('Edit ID') })
.meta({ id: 'SyncAssetEditDeleteV1' });
@ExtraModel()
export class SyncUserDeleteV1 {
@ApiProperty({ description: 'User ID' })
userId!: string;
}
class SyncAssetDeleteV1 extends createZodDto(SyncAssetDeleteV1Schema) {}
@ExtraModel()
export class SyncAssetExifV1 extends createZodDto(SyncAssetExifV1Schema) {}
@ExtraModel()
class SyncAssetMetadataV1 extends createZodDto(SyncAssetMetadataV1Schema) {}
@ExtraModel()
class SyncAssetMetadataDeleteV1 extends createZodDto(SyncAssetMetadataDeleteV1Schema) {}
@ExtraModel()
export class SyncAssetEditV1 extends createZodDto(SyncAssetEditV1Schema) {}
@ExtraModel()
class SyncAssetEditDeleteV1 extends createZodDto(SyncAssetEditDeleteV1Schema) {}
const SyncAlbumDeleteV1Schema = z
.object({ albumId: z.string().describe('Album ID') })
.meta({ id: 'SyncAlbumDeleteV1' });
const SyncAlbumUserDeleteV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'),
})
.meta({ id: 'SyncAlbumUserDeleteV1' });
const SyncAlbumUserV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'),
role: AlbumUserRoleSchema,
})
.meta({ id: 'SyncAlbumUserV1' });
const SyncAlbumV1Schema = z
.object({
id: z.string().describe('Album ID'),
ownerId: z.string().describe('Owner ID'),
name: z.string().describe('Album name'),
description: z.string().describe('Album description'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
thumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
isActivityEnabled: z.boolean().describe('Is activity enabled'),
order: AssetOrderSchema,
})
.meta({ id: 'SyncAlbumV1' });
const SyncAlbumToAssetV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
assetId: z.string().describe('Asset ID'),
})
.meta({ id: 'SyncAlbumToAssetV1' });
const SyncAlbumToAssetDeleteV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
assetId: z.string().describe('Asset ID'),
})
.meta({ id: 'SyncAlbumToAssetDeleteV1' });
@ExtraModel()
export class SyncPartnerV1 {
@ApiProperty({ description: 'Shared by ID' })
sharedById!: string;
@ApiProperty({ description: 'Shared with ID' })
sharedWithId!: string;
@ApiProperty({ description: 'In timeline' })
inTimeline!: boolean;
}
class SyncAlbumDeleteV1 extends createZodDto(SyncAlbumDeleteV1Schema) {}
@ExtraModel()
class SyncAlbumUserDeleteV1 extends createZodDto(SyncAlbumUserDeleteV1Schema) {}
@ExtraModel()
class SyncAlbumUserV1 extends createZodDto(SyncAlbumUserV1Schema) {}
@ExtraModel()
class SyncAlbumV1 extends createZodDto(SyncAlbumV1Schema) {}
@ExtraModel()
class SyncAlbumToAssetV1 extends createZodDto(SyncAlbumToAssetV1Schema) {}
@ExtraModel()
class SyncAlbumToAssetDeleteV1 extends createZodDto(SyncAlbumToAssetDeleteV1Schema) {}
const SyncMemoryV1Schema = z
.object({
id: z.string().describe('Memory ID'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
deletedAt: isoDatetimeToDate.nullable().describe('Deleted at'),
ownerId: z.string().describe('Owner ID'),
type: MemoryTypeSchema,
data: z.record(z.string(), z.unknown()).describe('Data'),
isSaved: z.boolean().describe('Is saved'),
memoryAt: isoDatetimeToDate.describe('Memory at'),
seenAt: isoDatetimeToDate.nullable().describe('Seen at'),
showAt: isoDatetimeToDate.nullable().describe('Show at'),
hideAt: isoDatetimeToDate.nullable().describe('Hide at'),
})
.meta({ id: 'SyncMemoryV1' });
const SyncMemoryDeleteV1Schema = z
.object({ memoryId: z.string().describe('Memory ID') })
.meta({ id: 'SyncMemoryDeleteV1' });
const SyncMemoryAssetV1Schema = z
.object({
memoryId: z.string().describe('Memory ID'),
assetId: z.string().describe('Asset ID'),
})
.meta({ id: 'SyncMemoryAssetV1' });
const SyncMemoryAssetDeleteV1Schema = z
.object({
memoryId: z.string().describe('Memory ID'),
assetId: z.string().describe('Asset ID'),
})
.meta({ id: 'SyncMemoryAssetDeleteV1' });
const SyncStackV1Schema = z
.object({
id: z.string().describe('Stack ID'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
primaryAssetId: z.string().describe('Primary asset ID'),
ownerId: z.string().describe('Owner ID'),
})
.meta({ id: 'SyncStackV1' });
const SyncStackDeleteV1Schema = z
.object({ stackId: z.string().describe('Stack ID') })
.meta({ id: 'SyncStackDeleteV1' });
const SyncPersonV1Schema = z
.object({
id: z.string().describe('Person ID'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
ownerId: z.string().describe('Owner ID'),
name: z.string().describe('Person name'),
birthDate: isoDatetimeToDate.nullable().describe('Birth date'),
isHidden: z.boolean().describe('Is hidden'),
isFavorite: z.boolean().describe('Is favorite'),
color: z.string().nullable().describe('Color'),
faceAssetId: z.string().nullable().describe('Face asset ID'),
})
.meta({ id: 'SyncPersonV1' });
const SyncPersonDeleteV1Schema = z
.object({ personId: z.string().describe('Person ID') })
.meta({ id: 'SyncPersonDeleteV1' });
const SyncAssetFaceV1Schema = z
.object({
id: z.string().describe('Asset face ID'),
assetId: z.string().describe('Asset ID'),
personId: z.string().nullable().describe('Person ID'),
imageWidth: z.int().describe('Image width'),
imageHeight: z.int().describe('Image height'),
boundingBoxX1: z.int().describe('Bounding box X1'),
boundingBoxY1: z.int().describe('Bounding box Y1'),
boundingBoxX2: z.int().describe('Bounding box X2'),
boundingBoxY2: z.int().describe('Bounding box Y2'),
sourceType: z.string().describe('Source type'),
})
.meta({ id: 'SyncAssetFaceV1' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
isVisible: z.boolean().describe('Is the face visible in the asset'),
}).meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceDeleteV1Schema = z
.object({ assetFaceId: z.string().describe('Asset face ID') })
.meta({ id: 'SyncAssetFaceDeleteV1' });
const SyncUserMetadataV1Schema = z
.object({
userId: z.string().describe('User ID'),
key: UserMetadataKeySchema,
value: z.record(z.string(), z.unknown()).describe('User metadata value'),
})
.meta({ id: 'SyncUserMetadataV1' });
const SyncUserMetadataDeleteV1Schema = z
.object({
userId: z.string().describe('User ID'),
key: UserMetadataKeySchema,
})
.meta({ id: 'SyncUserMetadataDeleteV1' });
const SyncAckV1Schema = z.object({}).meta({ id: 'SyncAckV1' });
const SyncResetV1Schema = z.object({}).meta({ id: 'SyncResetV1' });
const SyncCompleteV1Schema = z.object({}).meta({ id: 'SyncCompleteV1' });
@ExtraModel()
export class SyncPartnerDeleteV1 {
@ApiProperty({ description: 'Shared by ID' })
sharedById!: string;
@ApiProperty({ description: 'Shared with ID' })
sharedWithId!: string;
}
class SyncMemoryV1 extends createZodDto(SyncMemoryV1Schema) {}
@ExtraModel()
export class SyncAssetV1 {
@ApiProperty({ description: 'Asset ID' })
id!: string;
@ApiProperty({ description: 'Owner ID' })
ownerId!: string;
@ApiProperty({ description: 'Original file name' })
originalFileName!: string;
@ApiProperty({ description: 'Thumbhash' })
thumbhash!: string | null;
@ApiProperty({ description: 'Checksum' })
checksum!: string;
@ApiProperty({ description: 'File created at' })
fileCreatedAt!: Date | null;
@ApiProperty({ description: 'File modified at' })
fileModifiedAt!: Date | null;
@ApiProperty({ description: 'Local date time' })
localDateTime!: Date | null;
@ApiProperty({ description: 'Duration' })
duration!: string | null;
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
type!: AssetType;
@ApiProperty({ description: 'Deleted at' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Is favorite' })
isFavorite!: boolean;
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility' })
visibility!: AssetVisibility;
@ApiProperty({ description: 'Live photo video ID' })
livePhotoVideoId!: string | null;
@ApiProperty({ description: 'Stack ID' })
stackId!: string | null;
@ApiProperty({ description: 'Library ID' })
libraryId!: string | null;
@ApiProperty({ type: 'integer', description: 'Asset width' })
width!: number | null;
@ApiProperty({ type: 'integer', description: 'Asset height' })
height!: number | null;
@ApiProperty({ description: 'Is edited' })
isEdited!: boolean;
}
class SyncMemoryDeleteV1 extends createZodDto(SyncMemoryDeleteV1Schema) {}
@ExtraModel()
export class SyncAssetDeleteV1 {
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
}
class SyncMemoryAssetV1 extends createZodDto(SyncMemoryAssetV1Schema) {}
@ExtraModel()
export class SyncAssetExifV1 {
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Description' })
description!: string | null;
@ApiProperty({ type: 'integer', description: 'Exif image width' })
exifImageWidth!: number | null;
@ApiProperty({ type: 'integer', description: 'Exif image height' })
exifImageHeight!: number | null;
@ApiProperty({ type: 'integer', description: 'File size in byte' })
fileSizeInByte!: number | null;
@ApiProperty({ description: 'Orientation' })
orientation!: string | null;
@ApiProperty({ description: 'Date time original' })
dateTimeOriginal!: Date | null;
@ApiProperty({ description: 'Modify date' })
modifyDate!: Date | null;
@ApiProperty({ description: 'Time zone' })
timeZone!: string | null;
@ApiProperty({ type: 'number', format: 'double', description: 'Latitude' })
latitude!: number | null;
@ApiProperty({ type: 'number', format: 'double', description: 'Longitude' })
longitude!: number | null;
@ApiProperty({ description: 'Projection type' })
projectionType!: string | null;
@ApiProperty({ description: 'City' })
city!: string | null;
@ApiProperty({ description: 'State' })
state!: string | null;
@ApiProperty({ description: 'Country' })
country!: string | null;
@ApiProperty({ description: 'Make' })
make!: string | null;
@ApiProperty({ description: 'Model' })
model!: string | null;
@ApiProperty({ description: 'Lens model' })
lensModel!: string | null;
@ApiProperty({ type: 'number', format: 'double', description: 'F number' })
fNumber!: number | null;
@ApiProperty({ type: 'number', format: 'double', description: 'Focal length' })
focalLength!: number | null;
@ApiProperty({ type: 'integer', description: 'ISO' })
iso!: number | null;
@ApiProperty({ description: 'Exposure time' })
exposureTime!: string | null;
@ApiProperty({ description: 'Profile description' })
profileDescription!: string | null;
@ApiProperty({ type: 'integer', description: 'Rating' })
rating!: number | null;
@ApiProperty({ type: 'number', format: 'double', description: 'FPS' })
fps!: number | null;
}
class SyncMemoryAssetDeleteV1 extends createZodDto(SyncMemoryAssetDeleteV1Schema) {}
@ExtraModel()
export class SyncAssetEditV1 {
id!: string;
assetId!: string;
@ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' })
action!: AssetEditAction;
parameters!: object;
@ApiProperty({ type: 'integer' })
sequence!: number;
}
class SyncStackV1 extends createZodDto(SyncStackV1Schema) {}
@ExtraModel()
export class SyncAssetEditDeleteV1 {
editId!: string;
}
class SyncStackDeleteV1 extends createZodDto(SyncStackDeleteV1Schema) {}
@ExtraModel()
export class SyncAssetMetadataV1 {
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Key' })
key!: string;
@ApiProperty({ description: 'Value' })
value!: object;
}
class SyncPersonV1 extends createZodDto(SyncPersonV1Schema) {}
@ExtraModel()
export class SyncAssetMetadataDeleteV1 {
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Key' })
key!: string;
}
class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {}
@ExtraModel()
export class SyncAlbumDeleteV1 {
@ApiProperty({ description: 'Album ID' })
albumId!: string;
}
class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {}
@ExtraModel()
export class SyncAlbumUserDeleteV1 {
@ApiProperty({ description: 'Album ID' })
albumId!: string;
@ApiProperty({ description: 'User ID' })
userId!: string;
}
@ExtraModel()
export class SyncAlbumUserV1 {
@ApiProperty({ description: 'Album ID' })
albumId!: string;
@ApiProperty({ description: 'User ID' })
userId!: string;
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
role!: AlbumUserRole;
}
@ExtraModel()
export class SyncAlbumV1 {
@ApiProperty({ description: 'Album ID' })
id!: string;
@ApiProperty({ description: 'Owner ID' })
ownerId!: string;
@ApiProperty({ description: 'Album name' })
name!: string;
@ApiProperty({ description: 'Album description' })
description!: string;
@ApiProperty({ description: 'Created at' })
createdAt!: Date;
@ApiProperty({ description: 'Updated at' })
updatedAt!: Date;
@ApiProperty({ description: 'Thumbnail asset ID' })
thumbnailAssetId!: string | null;
@ApiProperty({ description: 'Is activity enabled' })
isActivityEnabled!: boolean;
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder' })
order!: AssetOrder;
}
@ExtraModel()
export class SyncAlbumToAssetV1 {
@ApiProperty({ description: 'Album ID' })
albumId!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
}
@ExtraModel()
export class SyncAlbumToAssetDeleteV1 {
@ApiProperty({ description: 'Album ID' })
albumId!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
}
@ExtraModel()
export class SyncMemoryV1 {
@ApiProperty({ description: 'Memory ID' })
id!: string;
@ApiProperty({ description: 'Created at' })
createdAt!: Date;
@ApiProperty({ description: 'Updated at' })
updatedAt!: Date;
@ApiProperty({ description: 'Deleted at' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Owner ID' })
ownerId!: string;
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type' })
type!: MemoryType;
@ApiProperty({ description: 'Data' })
data!: object;
@ApiProperty({ description: 'Is saved' })
isSaved!: boolean;
@ApiProperty({ description: 'Memory at' })
memoryAt!: Date;
@ApiProperty({ description: 'Seen at' })
seenAt!: Date | null;
@ApiProperty({ description: 'Show at' })
showAt!: Date | null;
@ApiProperty({ description: 'Hide at' })
hideAt!: Date | null;
}
@ExtraModel()
export class SyncMemoryDeleteV1 {
@ApiProperty({ description: 'Memory ID' })
memoryId!: string;
}
@ExtraModel()
export class SyncMemoryAssetV1 {
@ApiProperty({ description: 'Memory ID' })
memoryId!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
}
@ExtraModel()
export class SyncMemoryAssetDeleteV1 {
@ApiProperty({ description: 'Memory ID' })
memoryId!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
}
@ExtraModel()
export class SyncStackV1 {
@ApiProperty({ description: 'Stack ID' })
id!: string;
@ApiProperty({ description: 'Created at' })
createdAt!: Date;
@ApiProperty({ description: 'Updated at' })
updatedAt!: Date;
@ApiProperty({ description: 'Primary asset ID' })
primaryAssetId!: string;
@ApiProperty({ description: 'Owner ID' })
ownerId!: string;
}
@ExtraModel()
export class SyncStackDeleteV1 {
@ApiProperty({ description: 'Stack ID' })
stackId!: string;
}
@ExtraModel()
export class SyncPersonV1 {
@ApiProperty({ description: 'Person ID' })
id!: string;
@ApiProperty({ description: 'Created at' })
createdAt!: Date;
@ApiProperty({ description: 'Updated at' })
updatedAt!: Date;
@ApiProperty({ description: 'Owner ID' })
ownerId!: string;
@ApiProperty({ description: 'Person name' })
name!: string;
@ApiProperty({ description: 'Birth date' })
birthDate!: Date | null;
@ApiProperty({ description: 'Is hidden' })
isHidden!: boolean;
@ApiProperty({ description: 'Is favorite' })
isFavorite!: boolean;
@ApiProperty({ description: 'Color' })
color!: string | null;
@ApiProperty({ description: 'Face asset ID' })
faceAssetId!: string | null;
}
@ExtraModel()
export class SyncPersonDeleteV1 {
@ApiProperty({ description: 'Person ID' })
personId!: string;
}
@ExtraModel()
export class SyncAssetFaceV1 {
@ApiProperty({ description: 'Asset face ID' })
id!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Person ID' })
personId!: string | null;
@ApiProperty({ type: 'integer' })
imageWidth!: number;
@ApiProperty({ type: 'integer' })
imageHeight!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX2!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY2!: number;
@ApiProperty({ description: 'Source type' })
sourceType!: string;
}
@ExtraModel()
export class SyncAssetFaceV2 extends SyncAssetFaceV1 {
@ApiProperty({ description: 'Face deleted at' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Is the face visible in the asset' })
isVisible!: boolean;
}
class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {}
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
return faceV1;
}
@ExtraModel()
export class SyncAssetFaceDeleteV1 {
@ApiProperty({ description: 'Asset face ID' })
assetFaceId!: string;
}
class SyncAssetFaceDeleteV1 extends createZodDto(SyncAssetFaceDeleteV1Schema) {}
@ExtraModel()
export class SyncUserMetadataV1 {
@ApiProperty({ description: 'User ID' })
userId!: string;
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey', description: 'User metadata key' })
key!: UserMetadataKey;
@ApiProperty({ description: 'User metadata value' })
value!: UserMetadata[UserMetadataKey];
}
class SyncUserMetadataV1 extends createZodDto(SyncUserMetadataV1Schema) {}
@ExtraModel()
export class SyncUserMetadataDeleteV1 {
@ApiProperty({ description: 'User ID' })
userId!: string;
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey', description: 'User metadata key' })
key!: UserMetadataKey;
}
class SyncUserMetadataDeleteV1 extends createZodDto(SyncUserMetadataDeleteV1Schema) {}
@ExtraModel()
export class SyncAckV1 {}
class SyncAckV1 extends createZodDto(SyncAckV1Schema) {}
@ExtraModel()
export class SyncResetV1 {}
class SyncResetV1 extends createZodDto(SyncResetV1Schema) {}
@ExtraModel()
export class SyncCompleteV1 {}
class SyncCompleteV1 extends createZodDto(SyncCompleteV1Schema) {}
export type SyncItem = {
[SyncEntityType.AuthUserV1]: SyncAuthUserV1;
@ -541,35 +472,33 @@ export type SyncItem = {
[SyncEntityType.SyncResetV1]: SyncResetV1;
};
export class SyncStreamDto {
@ValidateEnum({ enum: SyncRequestType, name: 'SyncRequestType', each: true, description: 'Sync request types' })
types!: SyncRequestType[];
@ValidateBoolean({ optional: true, description: 'Reset sync state' })
reset?: boolean;
}
export class SyncAckDto {
@ValidateEnum({ enum: SyncEntityType, name: 'SyncEntityType', description: 'Sync entity type' })
type!: SyncEntityType;
@ApiProperty({ description: 'Acknowledgment ID' })
ack!: string;
}
export class SyncAckSetDto {
@ApiProperty({ description: 'Acknowledgment IDs (max 1000)' })
@ArrayMaxSize(1000)
@IsString({ each: true })
acks!: string[];
}
export class SyncAckDeleteDto {
@ValidateEnum({
enum: SyncEntityType,
name: 'SyncEntityType',
optional: true,
each: true,
description: 'Sync entity types to delete acks for',
const SyncStreamSchema = z
.object({
types: z.array(SyncRequestTypeSchema).describe('Sync request types'),
reset: z.boolean().optional().describe('Reset sync state'),
})
types?: SyncEntityType[];
}
.meta({ id: 'SyncStreamDto' });
const SyncAckSchema = z
.object({
type: SyncEntityTypeSchema,
ack: z.string().describe('Acknowledgment ID'),
})
.meta({ id: 'SyncAckDto' });
const SyncAckSetSchema = z
.object({
acks: z.array(z.string()).max(1000).describe('Acknowledgment IDs (max 1000)'),
})
.meta({ id: 'SyncAckSetDto' });
const SyncAckDeleteSchema = z
.object({
types: z.array(SyncEntityTypeSchema).optional().describe('Sync entity types to delete acks for'),
})
.meta({ id: 'SyncAckDeleteDto' });
export class SyncStreamDto extends createZodDto(SyncStreamSchema) {}
export class SyncAckDto extends createZodDto(SyncAckSchema) {}
export class SyncAckSetDto extends createZodDto(SyncAckSetSchema) {}
export class SyncAckDeleteDto extends createZodDto(SyncAckDeleteSchema) {}

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ValidateBoolean } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export class AdminOnboardingUpdateDto {
@ValidateBoolean({ description: 'Is admin onboarded' })
isOnboarded!: boolean;
}
const AdminOnboardingUpdateSchema = z
.object({
isOnboarded: z.boolean().describe('Is admin onboarded'),
})
.meta({ id: 'AdminOnboardingUpdateDto' });
export class AdminOnboardingResponseDto {
@ValidateBoolean({ description: 'Is admin onboarded' })
isOnboarded!: boolean;
}
const AdminOnboardingResponseSchema = z
.object({
isOnboarded: z.boolean().describe('Is admin onboarded'),
})
.meta({ id: 'AdminOnboardingResponseDto' });
export class ReverseGeocodingStateResponseDto {
@ApiProperty({ description: 'Last update timestamp' })
lastUpdate!: string | null;
@ApiProperty({ description: 'Last import file name' })
lastImportFileName!: string | null;
}
const ReverseGeocodingStateResponseSchema = z
.object({
lastUpdate: z.string().nullable().describe('Last update timestamp'),
lastImportFileName: z.string().nullable().describe('Last import file name'),
})
.meta({ id: 'ReverseGeocodingStateResponseDto' });
export class VersionCheckStateResponseDto {
@ApiProperty({ description: 'Last check timestamp' })
checkedAt!: string | null;
@ApiProperty({ description: 'Release version' })
releaseVersion!: string | null;
}
const VersionCheckStateResponseSchema = z
.object({
checkedAt: z.string().nullable().describe('Last check timestamp'),
releaseVersion: z.string().nullable().describe('Release version'),
})
.meta({ id: 'VersionCheckStateResponseDto' });
export class AdminOnboardingUpdateDto extends createZodDto(AdminOnboardingUpdateSchema) {}
export class AdminOnboardingResponseDto extends createZodDto(AdminOnboardingResponseSchema) {}
export class ReverseGeocodingStateResponseDto extends createZodDto(ReverseGeocodingStateResponseSchema) {}
export class VersionCheckStateResponseDto extends createZodDto(VersionCheckStateResponseSchema) {}

View File

@ -1,68 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Tag } from 'src/database';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
import { emptyStringToNull, hexColor } from 'src/validation';
import * as z from 'zod';
export class TagCreateDto {
@ApiProperty({ description: 'Tag name' })
@IsString()
@IsNotEmpty()
name!: string;
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)'),
})
.meta({ id: 'TagCreateDto' });
@ValidateUUID({ nullable: true, optional: true, description: 'Parent tag ID' })
parentId?: string | null;
const TagUpdateSchema = z
.object({
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
})
.meta({ id: 'TagUpdateDto' });
@ApiPropertyOptional({ description: 'Tag color (hex)' })
@IsHexColor()
@Optional({ nullable: true, emptyToNull: true })
color?: string;
}
const TagUpsertSchema = z
.object({
tags: z.array(z.string()).describe('Tag names to upsert'),
})
.meta({ id: 'TagUpsertDto' });
export class TagUpdateDto {
@ApiPropertyOptional({ description: 'Tag color (hex)' })
@Optional({ nullable: true, emptyToNull: true })
@ValidateHexColor()
color?: string | null;
}
const TagBulkAssetsSchema = z
.object({
tagIds: z.array(z.uuidv4()).describe('Tag IDs'),
assetIds: z.array(z.uuidv4()).describe('Asset IDs'),
})
.meta({ id: 'TagBulkAssetsDto' });
export class TagUpsertDto {
@ApiProperty({ description: 'Tag names to upsert' })
@IsString({ each: true })
@IsNotEmpty({ each: true })
tags!: string[];
}
const TagBulkAssetsResponseSchema = z
.object({
count: z.int().describe('Number of assets tagged'),
})
.meta({ id: 'TagBulkAssetsResponseDto' });
export class TagBulkAssetsDto {
@ValidateUUID({ each: true, description: 'Tag IDs' })
tagIds!: string[];
export const TagResponseSchema = z
.object({
id: z.string().describe('Tag ID'),
parentId: z.string().optional().describe('Parent tag ID'),
name: z.string().describe('Tag name'),
value: z.string().describe('Tag value (full path)'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
color: z.string().optional().describe('Tag color (hex)'),
})
.meta({ id: 'TagResponseDto' });
@ValidateUUID({ each: true, description: 'Asset IDs' })
assetIds!: string[];
}
export class TagBulkAssetsResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of assets tagged' })
count!: number;
}
export class TagResponseDto {
@ApiProperty({ description: 'Tag ID' })
id!: string;
@ApiPropertyOptional({ description: 'Parent tag ID' })
parentId?: string;
@ApiProperty({ description: 'Tag name' })
name!: string;
@ApiProperty({ description: 'Tag value (full path)' })
value!: string;
@ApiProperty({ description: 'Creation date', format: 'date-time' })
createdAt!: string;
@ApiProperty({ description: 'Last update date', format: 'date-time' })
updatedAt!: string;
@ApiPropertyOptional({ description: 'Tag color (hex)' })
color?: string;
}
export class TagCreateDto extends createZodDto(TagCreateSchema) {}
export class TagUpdateDto extends createZodDto(TagUpdateSchema) {}
export class TagUpsertDto extends createZodDto(TagUpsertSchema) {}
export class TagBulkAssetsDto extends createZodDto(TagBulkAssetsSchema) {}
export class TagBulkAssetsResponseDto extends createZodDto(TagBulkAssetsResponseSchema) {}
export class TagResponseDto extends createZodDto(TagResponseSchema) {}
export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
return {

View File

@ -1,230 +1,128 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import type { BBoxDto } from 'src/dtos/bbox.dto';
import { AssetOrder, AssetVisibility } from 'src/enum';
import { ValidateBBox } from 'src/utils/bbox';
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { BBoxSchema } from 'src/dtos/bbox.dto';
import { AssetOrderSchema, AssetVisibilitySchema } from 'src/enum';
import { stringToBool } from 'src/validation';
import * as z from 'zod';
export class TimeBucketDto {
@ValidateUUID({ optional: true, description: 'Filter assets by specific user ID' })
userId?: string;
const TimeBucketQueryBaseSchema = z
.object({
userId: z.uuidv4().optional().describe('Filter assets by specific user ID'),
albumId: z.uuidv4().optional().describe('Filter assets belonging to a specific album'),
personId: z.uuidv4().optional().describe('Filter assets containing a specific person (face recognition)'),
tagId: z.uuidv4().optional().describe('Filter assets with a specific tag'),
isFavorite: stringToBool
.optional()
.describe('Filter by favorite status (true for favorites only, false for non-favorites only)'),
isTrashed: stringToBool
.optional()
.describe('Filter by trash status (true for trashed assets only, false for non-trashed only)'),
withStacked: stringToBool
.optional()
.describe('Include stacked assets in the response. When true, only primary assets from stacks are returned.'),
withPartners: stringToBool.optional().describe('Include assets shared by partners'),
order: AssetOrderSchema.optional().describe(
'Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)',
),
visibility: AssetVisibilitySchema.optional().describe(
'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
),
withCoordinates: stringToBool.optional().describe('Include location data in the response'),
key: z.string().optional(),
slug: z.string().optional(),
bbox: z
.string()
.transform((value, ctx) => {
const parts = value.split(',');
if (parts.length !== 4) {
ctx.issues.push({
code: 'custom',
message: 'bbox must have 4 comma-separated numbers: west,south,east,north',
input: value,
});
return z.NEVER;
}
@ValidateUUID({ optional: true, description: 'Filter assets belonging to a specific album' })
albumId?: string;
const [west, south, east, north] = parts.map(Number);
if ([west, south, east, north].some((part) => Number.isNaN(part))) {
ctx.issues.push({
code: 'custom',
message: 'bbox parts must be valid numbers',
input: value,
});
return z.NEVER;
}
@ValidateUUID({ optional: true, description: 'Filter assets containing a specific person (face recognition)' })
personId?: string;
@ValidateUUID({ optional: true, description: 'Filter assets with a specific tag' })
tagId?: string;
@ValidateBoolean({
optional: true,
description: 'Filter by favorite status (true for favorites only, false for non-favorites only)',
return { west, south, east, north };
})
.pipe(BBoxSchema)
.optional()
.describe('Bounding box coordinates as west,south,east,north (WGS84)')
.meta({ example: '11.075683,49.416711,11.117589,49.454875' }),
})
isFavorite?: boolean;
.meta({ id: 'TimeBucketDto' });
@ValidateBoolean({
optional: true,
description: 'Filter by trash status (true for trashed assets only, false for non-trashed only)',
const TimeBucketSchema = TimeBucketQueryBaseSchema;
const TimeBucketAssetSchema = TimeBucketQueryBaseSchema.extend({
timeBucket: z.string().describe('Time bucket identifier in YYYY-MM-DD format').meta({ example: '2024-01-01' }),
}).meta({ id: 'TimeBucketAssetDto' });
const stackTupleSchema = z.array(z.string()).length(2).nullable();
const TimeBucketAssetResponseSchema = z
.object({
id: z.array(z.string()).describe('Array of asset IDs in the time bucket'),
ownerId: z.array(z.string()).describe('Array of owner IDs for each asset'),
ratio: z.array(z.number()).describe('Array of aspect ratios (width/height) for each asset'),
isFavorite: z.array(z.boolean()).describe('Array indicating whether each asset is favorited'),
visibility: z
.array(AssetVisibilitySchema)
.describe('Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)'),
isTrashed: z.array(z.boolean()).describe('Array indicating whether each asset is in the trash'),
isImage: z.array(z.boolean()).describe('Array indicating whether each asset is an image (false for videos)'),
thumbhash: z
.array(z.string().nullable())
.describe('Array of BlurHash strings for generating asset previews (base64 encoded)'),
fileCreatedAt: z.array(z.string()).describe('Array of file creation timestamps in UTC'),
localOffsetHours: z
.array(z.number())
.describe(
"Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.",
),
duration: z.array(z.string().nullable()).describe('Array of video durations in HH:MM:SS format (null for images)'),
stack: z
.array(stackTupleSchema)
.optional()
.describe('Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)'),
projectionType: z
.array(z.string().nullable())
.describe('Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL")'),
livePhotoVideoId: z
.array(z.string().nullable())
.describe('Array of live photo video asset IDs (null for non-live photos)'),
city: z.array(z.string().nullable()).describe('Array of city names extracted from EXIF GPS data'),
country: z.array(z.string().nullable()).describe('Array of country names extracted from EXIF GPS data'),
latitude: z
.array(z.number().nullable())
.optional()
.describe('Array of latitude coordinates extracted from EXIF GPS data'),
longitude: z
.array(z.number().nullable())
.optional()
.describe('Array of longitude coordinates extracted from EXIF GPS data'),
})
isTrashed?: boolean;
.meta({ id: 'TimeBucketAssetResponseDto' });
@ValidateBoolean({
optional: true,
description: 'Include stacked assets in the response. When true, only primary assets from stacks are returned.',
const TimeBucketsResponseSchema = z
.object({
timeBucket: z
.string()
.describe('Time bucket identifier in YYYY-MM-DD format representing the start of the time period')
.meta({ example: '2024-01-01' }),
count: z.int().describe('Number of assets in this time bucket').meta({ example: 42 }),
})
withStacked?: boolean;
.meta({ id: 'TimeBucketsResponseDto' });
@ValidateBoolean({ optional: true, description: 'Include assets shared by partners' })
withPartners?: boolean;
@ValidateEnum({
enum: AssetOrder,
name: 'AssetOrder',
description: 'Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)',
optional: true,
})
order?: AssetOrder;
@ValidateEnum({
enum: AssetVisibility,
name: 'AssetVisibility',
optional: true,
description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
})
visibility?: AssetVisibility;
@ValidateBoolean({
optional: true,
description: 'Include location data in the response',
})
withCoordinates?: boolean;
@ValidateBBox({ optional: true })
bbox?: BBoxDto;
}
export class TimeBucketAssetDto extends TimeBucketDto {
@ApiProperty({
type: 'string',
description: 'Time bucket identifier in YYYY-MM-DD format (e.g., "2024-01-01" for January 2024)',
example: '2024-01-01',
})
@IsString()
timeBucket!: string;
}
export class TimeBucketAssetResponseDto {
@ApiProperty({
type: 'array',
items: { type: 'string' },
description: 'Array of asset IDs in the time bucket',
})
id!: string[];
@ApiProperty({
type: 'array',
items: { type: 'string' },
description: 'Array of owner IDs for each asset',
})
ownerId!: string[];
@ApiProperty({
type: 'array',
items: { type: 'number' },
description: 'Array of aspect ratios (width/height) for each asset',
})
ratio!: number[];
@ApiProperty({
type: 'array',
items: { type: 'boolean' },
description: 'Array indicating whether each asset is favorited',
})
isFavorite!: boolean[];
@ValidateEnum({
enum: AssetVisibility,
name: 'AssetVisibility',
each: true,
description: 'Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
})
visibility!: AssetVisibility[];
@ApiProperty({
type: 'array',
items: { type: 'boolean' },
description: 'Array indicating whether each asset is in the trash',
})
isTrashed!: boolean[];
@ApiProperty({
type: 'array',
items: { type: 'boolean' },
description: 'Array indicating whether each asset is an image (false for videos)',
})
isImage!: boolean[];
@ApiProperty({
type: 'array',
items: { type: 'string', nullable: true },
description: 'Array of BlurHash strings for generating asset previews (base64 encoded)',
})
thumbhash!: (string | null)[];
@ApiProperty({
type: 'array',
items: { type: 'string' },
description: 'Array of file creation timestamps in UTC',
})
fileCreatedAt!: string[];
@ApiProperty({
type: 'array',
items: { type: 'number' },
description:
"Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.",
})
localOffsetHours!: number[];
@ApiProperty({
type: 'array',
items: { type: 'string', nullable: true },
description: 'Array of video durations in HH:MM:SS format (null for images)',
})
duration!: (string | null)[];
@ApiProperty({
type: 'array',
items: {
type: 'array',
items: { type: 'string' },
minItems: 2,
maxItems: 2,
nullable: true,
},
description: 'Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)',
})
stack?: ([string, string] | null)[];
@ApiProperty({
type: 'array',
items: { type: 'string', nullable: true },
description: 'Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL")',
})
projectionType!: (string | null)[];
@ApiProperty({
type: 'array',
items: { type: 'string', nullable: true },
description: 'Array of live photo video asset IDs (null for non-live photos)',
})
livePhotoVideoId!: (string | null)[];
@ApiProperty({
type: 'array',
items: { type: 'string', nullable: true },
description: 'Array of city names extracted from EXIF GPS data',
})
city!: (string | null)[];
@ApiProperty({
type: 'array',
items: { type: 'string', nullable: true },
description: 'Array of country names extracted from EXIF GPS data',
})
country!: (string | null)[];
@ApiProperty({
type: 'array',
required: false,
items: { type: 'number', nullable: true },
description: 'Array of latitude coordinates extracted from EXIF GPS data',
})
latitude!: number[];
@ApiProperty({
type: 'array',
required: false,
items: { type: 'number', nullable: true },
description: 'Array of longitude coordinates extracted from EXIF GPS data',
})
longitude!: number[];
}
export class TimeBucketsResponseDto {
@ApiProperty({
type: 'string',
description: 'Time bucket identifier in YYYY-MM-DD format representing the start of the time period',
example: '2024-01-01',
})
timeBucket!: string;
@ApiProperty({
type: 'integer',
description: 'Number of assets in this time bucket',
example: 42,
})
count!: number;
}
export class TimeBucketDto extends createZodDto(TimeBucketSchema) {}
export class TimeBucketAssetDto extends createZodDto(TimeBucketAssetSchema) {}
export class TimeBucketAssetResponseDto extends createZodDto(TimeBucketAssetResponseSchema) {}
export class TimeBucketsResponseDto extends createZodDto(TimeBucketsResponseSchema) {}

View File

@ -1,6 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { createZodDto } from 'nestjs-zod';
import * as z from 'zod';
export class TrashResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of items in trash' })
count!: number;
}
const TrashResponseSchema = z
.object({
count: z.int().describe('Number of items in trash'),
})
.meta({ id: 'TrashResponseDto' });
export class TrashResponseDto extends createZodDto(TrashResponseSchema) {}

View File

@ -1,302 +1,212 @@
import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsPositive, ValidateNested } from 'class-validator';
import { AssetOrder, UserAvatarColor } from 'src/enum';
import { createZodDto } from 'nestjs-zod';
import { AssetOrderSchema, UserAvatarColorSchema } from 'src/enum';
import { UserPreferences } from 'src/types';
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
import * as z from 'zod';
class AvatarUpdate {
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' })
color?: UserAvatarColor;
}
const AlbumsUpdateSchema = z
.object({
defaultAssetOrder: AssetOrderSchema.optional(),
})
.optional()
.describe('Album preferences')
.meta({ id: 'AlbumsUpdate' });
class MemoriesUpdate {
@ValidateBoolean({ optional: true, description: 'Whether memories are enabled' })
enabled?: boolean;
const AvatarUpdateSchema = z
.object({
color: UserAvatarColorSchema.optional(),
})
.optional()
.meta({ id: 'AvatarUpdate' });
@Optional()
@IsInt()
@IsPositive()
@ApiProperty({ type: 'integer', description: 'Memory duration in seconds' })
duration?: number;
}
const MemoriesUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether memories are enabled'),
duration: z.int().min(1).optional().describe('Memory duration in seconds'),
})
.optional()
.meta({ id: 'MemoriesUpdate' });
class RatingsUpdate {
@ValidateBoolean({ optional: true, description: 'Whether ratings are enabled' })
enabled?: boolean;
}
const RatingsUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether ratings are enabled'),
})
.optional()
.meta({ id: 'RatingsUpdate' });
@ApiSchema({ description: 'Album preferences' })
class AlbumsUpdate {
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true, description: 'Default asset order for albums' })
defaultAssetOrder?: AssetOrder;
}
const FoldersUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether folders are enabled'),
sidebarWeb: z.boolean().optional().describe('Whether folders appear in web sidebar'),
})
.optional()
.meta({ id: 'FoldersUpdate' });
class FoldersUpdate {
@ValidateBoolean({ optional: true, description: 'Whether folders are enabled' })
enabled?: boolean;
const PeopleUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether people are enabled'),
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
})
.optional()
.meta({ id: 'PeopleUpdate' });
@ValidateBoolean({ optional: true, description: 'Whether folders appear in web sidebar' })
sidebarWeb?: boolean;
}
const SharedLinksUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether shared links are enabled'),
sidebarWeb: z.boolean().optional().describe('Whether shared links appear in web sidebar'),
})
.optional()
.meta({ id: 'SharedLinksUpdate' });
class PeopleUpdate {
@ValidateBoolean({ optional: true, description: 'Whether people are enabled' })
enabled?: boolean;
const TagsUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether tags are enabled'),
sidebarWeb: z.boolean().optional().describe('Whether tags appear in web sidebar'),
})
.optional()
.meta({ id: 'TagsUpdate' });
@ValidateBoolean({ optional: true, description: 'Whether people appear in web sidebar' })
sidebarWeb?: boolean;
}
const EmailNotificationsUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether email notifications are enabled'),
albumInvite: z.boolean().optional().describe('Whether to receive email notifications for album invites'),
albumUpdate: z.boolean().optional().describe('Whether to receive email notifications for album updates'),
})
.optional()
.meta({ id: 'EmailNotificationsUpdate' });
class SharedLinksUpdate {
@ValidateBoolean({ optional: true, description: 'Whether shared links are enabled' })
enabled?: boolean;
const DownloadUpdateSchema = z
.object({
archiveSize: z.int().min(1).optional().describe('Maximum archive size in bytes'),
includeEmbeddedVideos: z.boolean().optional().describe('Whether to include embedded videos in downloads'),
})
.optional()
.meta({ id: 'DownloadUpdate' });
@ValidateBoolean({ optional: true, description: 'Whether shared links appear in web sidebar' })
sidebarWeb?: boolean;
}
const PurchaseUpdateSchema = z
.object({
showSupportBadge: z.boolean().optional().describe('Whether to show support badge'),
hideBuyButtonUntil: z.string().optional().describe('Date until which to hide buy button'),
})
.optional()
.meta({ id: 'PurchaseUpdate' });
class TagsUpdate {
@ValidateBoolean({ optional: true, description: 'Whether tags are enabled' })
enabled?: boolean;
const CastUpdateSchema = z
.object({
gCastEnabled: z.boolean().optional().describe('Whether Google Cast is enabled'),
})
.optional()
.meta({ id: 'CastUpdate' });
@ValidateBoolean({ optional: true, description: 'Whether tags appear in web sidebar' })
sidebarWeb?: boolean;
}
const UserPreferencesUpdateSchema = z
.object({
albums: AlbumsUpdateSchema,
avatar: AvatarUpdateSchema,
cast: CastUpdateSchema,
download: DownloadUpdateSchema,
emailNotifications: EmailNotificationsUpdateSchema,
folders: FoldersUpdateSchema,
memories: MemoriesUpdateSchema,
people: PeopleUpdateSchema,
purchase: PurchaseUpdateSchema,
ratings: RatingsUpdateSchema,
sharedLinks: SharedLinksUpdateSchema,
tags: TagsUpdateSchema,
})
.meta({ id: 'UserPreferencesUpdateDto' });
class EmailNotificationsUpdate {
@ValidateBoolean({ optional: true, description: 'Whether email notifications are enabled' })
enabled?: boolean;
const AlbumsResponseSchema = z
.object({
defaultAssetOrder: AssetOrderSchema,
})
.meta({ id: 'AlbumsResponse' });
@ValidateBoolean({ optional: true, description: 'Whether to receive email notifications for album invites' })
albumInvite?: boolean;
const FoldersResponseSchema = z
.object({
enabled: z.boolean().describe('Whether folders are enabled'),
sidebarWeb: z.boolean().describe('Whether folders appear in web sidebar'),
})
.meta({ id: 'FoldersResponse' });
@ValidateBoolean({ optional: true, description: 'Whether to receive email notifications for album updates' })
albumUpdate?: boolean;
}
const MemoriesResponseSchema = z
.object({
enabled: z.boolean().describe('Whether memories are enabled'),
duration: z.int().describe('Memory duration in seconds'),
})
.meta({ id: 'MemoriesResponse' });
class DownloadUpdate implements Partial<DownloadResponse> {
@Optional()
@IsInt()
@IsPositive()
@ApiPropertyOptional({ type: 'integer', description: 'Maximum archive size in bytes' })
archiveSize?: number;
const PeopleResponseSchema = z
.object({
enabled: z.boolean().describe('Whether people are enabled'),
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
})
.meta({ id: 'PeopleResponse' });
@ValidateBoolean({ optional: true, description: 'Whether to include embedded videos in downloads' })
includeEmbeddedVideos?: boolean;
}
const RatingsResponseSchema = z
.object({
enabled: z.boolean().describe('Whether ratings are enabled'),
})
.meta({ id: 'RatingsResponse' });
class PurchaseUpdate {
@ValidateBoolean({ optional: true, description: 'Whether to show support badge' })
showSupportBadge?: boolean;
const SharedLinksResponseSchema = z
.object({
enabled: z.boolean().describe('Whether shared links are enabled'),
sidebarWeb: z.boolean().describe('Whether shared links appear in web sidebar'),
})
.meta({ id: 'SharedLinksResponse' });
@ApiPropertyOptional({ description: 'Date until which to hide buy button' })
@IsDateString()
@Optional()
hideBuyButtonUntil?: string;
}
const TagsResponseSchema = z
.object({
enabled: z.boolean().describe('Whether tags are enabled'),
sidebarWeb: z.boolean().describe('Whether tags appear in web sidebar'),
})
.meta({ id: 'TagsResponse' });
class CastUpdate {
@ValidateBoolean({ optional: true, description: 'Whether Google Cast is enabled' })
gCastEnabled?: boolean;
}
const EmailNotificationsResponseSchema = z
.object({
enabled: z.boolean().describe('Whether email notifications are enabled'),
albumInvite: z.boolean().describe('Whether to receive email notifications for album invites'),
albumUpdate: z.boolean().describe('Whether to receive email notifications for album updates'),
})
.meta({ id: 'EmailNotificationsResponse' });
export class UserPreferencesUpdateDto {
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => AlbumsUpdate)
albums?: AlbumsUpdate;
const DownloadResponseSchema = z
.object({
archiveSize: z.int().describe('Maximum archive size in bytes'),
includeEmbeddedVideos: z.boolean().describe('Whether to include embedded videos in downloads'),
})
.meta({ id: 'DownloadResponse' });
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => FoldersUpdate)
folders?: FoldersUpdate;
const PurchaseResponseSchema = z
.object({
showSupportBadge: z.boolean().describe('Whether to show support badge'),
hideBuyButtonUntil: z.string().describe('Date until which to hide buy button'),
})
.meta({ id: 'PurchaseResponse' });
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => MemoriesUpdate)
memories?: MemoriesUpdate;
const CastResponseSchema = z
.object({
gCastEnabled: z.boolean().describe('Whether Google Cast is enabled'),
})
.meta({ id: 'CastResponse' });
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => PeopleUpdate)
people?: PeopleUpdate;
const UserPreferencesResponseSchema = z
.object({
albums: AlbumsResponseSchema,
folders: FoldersResponseSchema,
memories: MemoriesResponseSchema,
people: PeopleResponseSchema,
ratings: RatingsResponseSchema,
sharedLinks: SharedLinksResponseSchema,
tags: TagsResponseSchema,
emailNotifications: EmailNotificationsResponseSchema,
download: DownloadResponseSchema,
purchase: PurchaseResponseSchema,
cast: CastResponseSchema,
})
.meta({ id: 'UserPreferencesResponseDto' });
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => RatingsUpdate)
ratings?: RatingsUpdate;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined, required: false })
@Optional()
@ValidateNested()
@Type(() => SharedLinksUpdate)
sharedLinks?: SharedLinksUpdate;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => TagsUpdate)
tags?: TagsUpdate;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => AvatarUpdate)
avatar?: AvatarUpdate;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => EmailNotificationsUpdate)
emailNotifications?: EmailNotificationsUpdate;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => DownloadUpdate)
download?: DownloadUpdate;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => PurchaseUpdate)
purchase?: PurchaseUpdate;
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Optional()
@ValidateNested()
@Type(() => CastUpdate)
cast?: CastUpdate;
}
class AlbumsResponse {
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Default asset order for albums' })
defaultAssetOrder: AssetOrder = AssetOrder.Desc;
}
class RatingsResponse {
@ApiProperty({ description: 'Whether ratings are enabled' })
enabled: boolean = false;
}
class MemoriesResponse {
@ApiProperty({ description: 'Whether memories are enabled' })
enabled: boolean = true;
@ApiProperty({ type: 'integer', description: 'Memory duration in seconds' })
duration: number = 5;
}
class FoldersResponse {
@ApiProperty({ description: 'Whether folders are enabled' })
enabled: boolean = false;
@ApiProperty({ description: 'Whether folders appear in web sidebar' })
sidebarWeb: boolean = false;
}
class PeopleResponse {
@ApiProperty({ description: 'Whether people are enabled' })
enabled: boolean = true;
@ApiProperty({ description: 'Whether people appear in web sidebar' })
sidebarWeb: boolean = false;
}
class TagsResponse {
@ApiProperty({ description: 'Whether tags are enabled' })
enabled: boolean = true;
@ApiProperty({ description: 'Whether tags appear in web sidebar' })
sidebarWeb: boolean = true;
}
class SharedLinksResponse {
@ApiProperty({ description: 'Whether shared links are enabled' })
enabled: boolean = true;
@ApiProperty({ description: 'Whether shared links appear in web sidebar' })
sidebarWeb: boolean = false;
}
class EmailNotificationsResponse {
@ApiProperty({ description: 'Whether email notifications are enabled' })
enabled!: boolean;
@ApiProperty({ description: 'Whether to receive email notifications for album invites' })
albumInvite!: boolean;
@ApiProperty({ description: 'Whether to receive email notifications for album updates' })
albumUpdate!: boolean;
}
class DownloadResponse {
@ApiProperty({ type: 'integer', description: 'Maximum archive size in bytes' })
archiveSize!: number;
@ApiProperty({ description: 'Whether to include embedded videos in downloads' })
includeEmbeddedVideos: boolean = false;
}
class PurchaseResponse {
@ApiProperty({ description: 'Whether to show support badge' })
showSupportBadge!: boolean;
@ApiProperty({ description: 'Date until which to hide buy button' })
hideBuyButtonUntil!: string;
}
class CastResponse {
@ApiProperty({ description: 'Whether Google Cast is enabled' })
gCastEnabled: boolean = false;
}
export class UserPreferencesResponseDto implements UserPreferences {
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
albums!: AlbumsResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
folders!: FoldersResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
memories!: MemoriesResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
people!: PeopleResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
ratings!: RatingsResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
sharedLinks!: SharedLinksResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
tags!: TagsResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
emailNotifications!: EmailNotificationsResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
download!: DownloadResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
purchase!: PurchaseResponse;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
cast!: CastResponse;
}
export class UserPreferencesUpdateDto extends createZodDto(UserPreferencesUpdateSchema) {}
export class UserPreferencesResponseDto extends createZodDto(UserPreferencesResponseSchema) {}
export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => {
return preferences;

View File

@ -1,16 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { createZodDto } from 'nestjs-zod';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { isoDatetimeToDate } from 'src/validation';
import * as z from 'zod';
export class CreateProfileImageDto {
@ApiProperty({ type: 'string', format: 'binary', description: 'Profile image file' })
[UploadFieldName.PROFILE_DATA]!: Express.Multer.File;
}
export class CreateProfileImageResponseDto {
@ApiProperty({ description: 'User ID' })
userId!: string;
@ApiProperty({ description: 'Profile image change date', format: 'date-time' })
profileChangedAt!: Date;
@ApiProperty({ description: 'Profile image file path' })
profileImagePath!: string;
}
const CreateProfileImageResponseSchema = z
.object({
userId: z.string().describe('User ID'),
profileChangedAt: isoDatetimeToDate.describe('Profile image change date'),
profileImagePath: z.string().describe('Profile image file path'),
})
.meta({ id: 'CreateProfileImageResponseDto' });
export class CreateProfileImageResponseDto extends createZodDto(CreateProfileImageResponseSchema) {}

View File

@ -1,69 +1,63 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UserAdminCreateDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { UserAdminCreateSchema, UserUpdateMeSchema } from 'src/dtos/user.dto';
describe('update user DTO', () => {
it('should allow emails without a tld', async () => {
it('should allow emails without a tld', () => {
const someEmail = 'test@test';
const dto = plainToInstance(UserUpdateMeDto, {
const result = UserUpdateMeSchema.safeParse({
email: someEmail,
id: '3fe388e4-2078-44d7-b36c-39d9dee3a657',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.email).toEqual(someEmail);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toEqual(someEmail);
}
});
});
describe('create user DTO', () => {
it('validates the email', async () => {
const params: Partial<UserAdminCreateDto> = {
email: undefined,
it('validates the email', () => {
expect(UserAdminCreateSchema.safeParse({ password: 'password', name: 'name' }).success).toBe(false);
expect(
UserAdminCreateSchema.safeParse({ email: 'invalid email', password: 'password', name: 'name' }).success,
).toBe(false);
const result = UserAdminCreateSchema.safeParse({
email: 'valid@email.com',
password: 'password',
name: 'name',
};
let dto: UserAdminCreateDto = plainToInstance(UserAdminCreateDto, params);
let errors = await validate(dto);
expect(errors).toHaveLength(1);
params.email = 'invalid email';
dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto);
expect(errors).toHaveLength(1);
params.email = 'valid@email.com';
dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto);
expect(errors).toHaveLength(0);
});
expect(result.success).toBe(true);
});
it('validates invalid email type', async () => {
let dto = plainToInstance(UserAdminCreateDto, {
email: [],
password: 'some password',
name: 'some name',
});
expect(await validate(dto)).toHaveLength(1);
it('validates invalid email type', () => {
expect(
UserAdminCreateSchema.safeParse({
email: [],
password: 'some password',
name: 'some name',
}).success,
).toBe(false);
dto = plainToInstance(UserAdminCreateDto, {
email: {},
password: 'some password',
name: 'some name',
});
expect(await validate(dto)).toHaveLength(1);
expect(
UserAdminCreateSchema.safeParse({
email: {},
password: 'some password',
name: 'some name',
}).success,
).toBe(false);
});
it('should allow emails without a tld', async () => {
it('should allow emails without a tld', () => {
const someEmail = 'test@test';
const dto = plainToInstance(UserAdminCreateDto, {
const result = UserAdminCreateSchema.safeParse({
email: someEmail,
password: 'some password',
name: 'some name',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.email).toEqual(someEmail);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toEqual(someEmail);
}
});
});

View File

@ -1,65 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
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 { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
import * as z from 'zod';
export class UserUpdateMeDto {
@ApiPropertyOptional({ description: 'User email' })
@Optional()
@IsEmail({ require_tld: false })
@Transform(toEmail)
email?: string;
// TODO: migrate to the other change password endpoint
@ApiPropertyOptional({ description: 'User password (deprecated, use change password endpoint)' })
@Optional()
@IsNotEmpty()
@IsString()
password?: string;
@ApiPropertyOptional({ description: 'User name' })
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@ValidateEnum({
enum: UserAvatarColor,
name: 'UserAvatarColor',
optional: true,
nullable: true,
description: 'Avatar color',
export const UserUpdateMeSchema = z
.object({
email: toEmail.optional().describe('User email'),
password: z
.string()
.optional()
.describe('User password (deprecated, use change password endpoint)')
.meta({ deprecated: true }),
name: z.string().optional().describe('User name'),
avatarColor: UserAvatarColorSchema.nullish(),
})
avatarColor?: UserAvatarColor | null;
}
.meta({ id: 'UserUpdateMeDto' });
export class UserResponseDto {
@ApiProperty({ description: 'User ID' })
id!: string;
@ApiProperty({ description: 'User name' })
name!: string;
@ApiProperty({ description: 'User email' })
email!: string;
@ApiProperty({ description: 'Profile image path' })
profileImagePath!: string;
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'Avatar color' })
avatarColor!: UserAvatarColor;
@ApiProperty({ description: 'Profile change date', format: 'date-time' })
profileChangedAt!: string;
}
export class UserUpdateMeDto extends createZodDto(UserUpdateMeSchema) {}
export class UserLicense {
@ApiProperty({ description: 'License key' })
licenseKey!: string;
@ApiProperty({ description: 'Activation key' })
activationKey!: string;
@ApiProperty({ description: 'Activation date' })
activatedAt!: Date;
}
export const UserResponseSchema = z
.object({
id: z.uuidv4().describe('User ID'),
name: z.string().describe('User name'),
email: toEmail.describe('User email'),
profileImagePath: z.string().describe('Profile image path'),
avatarColor: UserAvatarColorSchema,
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
profileChangedAt: z.string().meta({ format: 'date-time' }).describe('Profile change date'),
})
.meta({ id: 'UserResponseDto' });
export class UserResponseDto extends createZodDto(UserResponseSchema) {}
const licenseKeyRegex = /^IM(SV|CL)(-[\dA-Za-z]{4}){8}$/;
export const UserLicenseSchema = z
.object({
licenseKey: z.string().regex(licenseKeyRegex).describe(`License key (format: ${licenseKeyRegex.toString()})`),
activationKey: z.string().describe('Activation key'),
activatedAt: isoDatetimeToDate.describe('Activation date'),
})
.meta({ id: 'UserLicense' });
const emailToAvatarColor = (email: string): UserAvatarColor => {
const values = Object.values(UserAvatarColor);
@ -80,144 +65,77 @@ export const mapUser = (entity: MaybeDehydrated<User | UserAdmin>): UserResponse
};
};
export class UserAdminSearchDto {
@ValidateBoolean({ optional: true, description: 'Include deleted users' })
withDeleted?: boolean;
@ValidateUUID({ optional: true, description: 'User ID filter' })
id?: string;
}
export class UserAdminCreateDto {
@ApiProperty({ description: 'User email' })
@IsEmail({ require_tld: false })
@Transform(toEmail)
email!: string;
@ApiProperty({ description: 'User password' })
@IsString()
password!: string;
@ApiProperty({ description: 'User name' })
@IsNotEmpty()
@IsString()
name!: string;
@ValidateEnum({
enum: UserAvatarColor,
name: 'UserAvatarColor',
optional: true,
nullable: true,
description: 'Avatar color',
const UserAdminSearchSchema = z
.object({
withDeleted: stringToBool.optional().describe('Include deleted users'),
id: z.uuidv4().optional().describe('User ID filter'),
})
avatarColor?: UserAvatarColor | null;
.meta({ id: 'UserAdminSearchDto' });
@ApiPropertyOptional({ description: 'PIN code' })
@PinCode({ optional: true, nullable: true, emptyToNull: true })
pinCode?: string | null;
export class UserAdminSearchDto extends createZodDto(UserAdminSearchSchema) {}
@ApiPropertyOptional({ description: 'Storage label' })
@Optional({ nullable: true })
@IsString()
@Transform(toSanitized)
storageLabel?: string | null;
@ApiPropertyOptional({ type: 'integer', format: 'int64', description: 'Storage quota in bytes' })
@Optional({ nullable: true })
@IsInt()
@Min(0)
quotaSizeInBytes?: number | null;
@ValidateBoolean({ optional: true, description: 'Require password change on next login' })
shouldChangePassword?: boolean;
@ValidateBoolean({ optional: true, description: 'Send notification email' })
notify?: boolean;
@ValidateBoolean({ optional: true, description: 'Grant admin privileges' })
isAdmin?: boolean;
}
export class UserAdminUpdateDto {
@ApiPropertyOptional({ description: 'User email' })
@Optional()
@IsEmail({ require_tld: false })
@Transform(toEmail)
email?: string;
@ApiPropertyOptional({ description: 'User password' })
@Optional()
@IsNotEmpty()
@IsString()
password?: string;
@ApiPropertyOptional({ description: 'PIN code' })
@PinCode({ optional: true, nullable: true, emptyToNull: true })
pinCode?: string | null;
@ApiPropertyOptional({ description: 'User name' })
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@ValidateEnum({
enum: UserAvatarColor,
name: 'UserAvatarColor',
optional: true,
nullable: true,
description: 'Avatar color',
export const UserAdminCreateSchema = z
.object({
email: toEmail.describe('User email'),
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' }),
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'),
notify: z.boolean().optional().describe('Send notification email'),
isAdmin: z.boolean().optional().describe('Grant admin privileges'),
})
avatarColor?: UserAvatarColor | null;
.meta({ id: 'UserAdminCreateDto' });
@ApiPropertyOptional({ description: 'Storage label' })
@Optional({ nullable: true })
@IsString()
@Transform(toSanitized)
storageLabel?: string | null;
export class UserAdminCreateDto extends createZodDto(UserAdminCreateSchema) {}
@ValidateBoolean({ optional: true, description: 'Require password change on next login' })
shouldChangePassword?: boolean;
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' }),
name: z.string().optional().describe('User name'),
avatarColor: UserAvatarColorSchema.nullish(),
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
shouldChangePassword: z.boolean().optional().describe('Require password change on next login'),
quotaSizeInBytes: z.int().min(0).nullish().describe('Storage quota in bytes'),
isAdmin: z.boolean().optional().describe('Grant admin privileges'),
})
.meta({ id: 'UserAdminUpdateDto' });
@ApiPropertyOptional({ type: 'integer', format: 'int64', description: 'Storage quota in bytes' })
@Optional({ nullable: true })
@IsInt()
@Min(0)
quotaSizeInBytes?: number | null;
export class UserAdminUpdateDto extends createZodDto(UserAdminUpdateSchema) {}
@ValidateBoolean({ optional: true, description: 'Grant admin privileges' })
isAdmin?: boolean;
}
const UserAdminDeleteSchema = z
.object({
force: z.boolean().optional().describe('Force delete even if user has assets'),
})
.meta({ id: 'UserAdminDeleteDto' });
export class UserAdminDeleteDto {
@ValidateBoolean({ optional: true, description: 'Force delete even if user has assets' })
force?: boolean;
}
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
export class UserAdminResponseDto extends UserResponseDto {
@ApiProperty({ description: 'Storage label' })
storageLabel!: string | null;
@ApiProperty({ description: 'Require password change on next login' })
shouldChangePassword!: boolean;
@ApiProperty({ description: 'Is admin user' })
isAdmin!: boolean;
@ApiProperty({ description: 'Creation date' })
createdAt!: Date;
@ApiProperty({ description: 'Deletion date' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Last update date' })
updatedAt!: Date;
@ApiProperty({ description: 'OAuth ID' })
oauthId!: string;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage quota in bytes' })
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
quotaUsageInBytes!: number | null;
@ValidateEnum({ enum: UserStatus, name: 'UserStatus', description: 'User status' })
status!: string;
@ApiProperty({ description: 'User license' })
license!: UserLicense | null;
}
const UserAdminResponseSchema = UserResponseSchema.extend({
storageLabel: z.string().nullable().describe('Storage label'),
shouldChangePassword: z.boolean().describe('Require password change on next login'),
isAdmin: z.boolean().describe('Is admin user'),
createdAt: isoDatetimeToDate.describe('Creation date'),
deletedAt: isoDatetimeToDate.nullable().describe('Deletion date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
oauthId: z.string().describe('OAuth ID'),
quotaSizeInBytes: z.int().min(0).nullable().describe('Storage quota in bytes'),
quotaUsageInBytes: z.int().min(0).nullable().describe('Storage usage in bytes'),
status: UserStatusSchema,
license: UserLicenseSchema.nullable(),
}).meta({ id: 'UserAdminResponseDto' });
export class UserAdminResponseDto extends createZodDto(UserAdminResponseSchema) {}
export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
const metadata = entity.metadata || [];
@ -237,6 +155,6 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status,
license: license ? { ...license, activatedAt: new Date(license?.activatedAt) } : null,
};
license: license ? { ...license, activatedAt: license?.activatedAt } : null,
} as UserAdminResponseDto;
}

View File

@ -1,143 +1,84 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
import { WorkflowAction, WorkflowFilter } from 'src/database';
import { PluginTriggerType } from 'src/enum';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import type { WorkflowAction, WorkflowFilter } from 'src/database';
import { PluginTriggerTypeSchema } from 'src/enum';
import { ActionConfigSchema, FilterConfigSchema } from 'src/types/plugin-schema.types';
import * as z from 'zod';
export class WorkflowFilterItemDto {
@ApiProperty({ description: 'Plugin filter ID' })
@IsUUID()
pluginFilterId!: string;
@ApiPropertyOptional({ description: 'Filter configuration' })
@IsObject()
@Optional()
filterConfig?: FilterConfig;
}
export class WorkflowActionItemDto {
@ApiProperty({ description: 'Plugin action ID' })
@IsUUID()
pluginActionId!: string;
@ApiPropertyOptional({ description: 'Action configuration' })
@IsObject()
@Optional()
actionConfig?: ActionConfig;
}
export class WorkflowCreateDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' })
triggerType!: PluginTriggerType;
@ApiProperty({ description: 'Workflow name' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiPropertyOptional({ description: 'Workflow description' })
@IsString()
@Optional()
description?: string;
@ValidateBoolean({ optional: true, description: 'Workflow enabled' })
enabled?: boolean;
@ApiProperty({ description: 'Workflow filters' })
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
filters!: WorkflowFilterItemDto[];
@ApiProperty({ description: 'Workflow actions' })
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
actions!: WorkflowActionItemDto[];
}
export class WorkflowUpdateDto {
@ValidateEnum({
enum: PluginTriggerType,
name: 'PluginTriggerType',
optional: true,
description: 'Workflow trigger type',
const WorkflowFilterItemSchema = z
.object({
pluginFilterId: z.uuidv4().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.optional(),
})
triggerType?: PluginTriggerType;
.meta({ id: 'WorkflowFilterItemDto' });
@ApiPropertyOptional({ description: 'Workflow name' })
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
const WorkflowActionItemSchema = z
.object({
pluginActionId: z.uuidv4().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.optional(),
})
.meta({ id: 'WorkflowActionItemDto' });
@ApiPropertyOptional({ description: 'Workflow description' })
@IsString()
@Optional()
description?: string;
const WorkflowCreateSchema = z
.object({
triggerType: PluginTriggerTypeSchema,
name: z.string().describe('Workflow name'),
description: z.string().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
filters: z.array(WorkflowFilterItemSchema).describe('Workflow filters'),
actions: z.array(WorkflowActionItemSchema).describe('Workflow actions'),
})
.meta({ id: 'WorkflowCreateDto' });
@ValidateBoolean({ optional: true, description: 'Workflow enabled' })
enabled?: boolean;
const WorkflowUpdateSchema = z
.object({
triggerType: PluginTriggerTypeSchema.optional(),
name: z.string().optional().describe('Workflow name'),
description: z.string().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
filters: z.array(WorkflowFilterItemSchema).optional().describe('Workflow filters'),
actions: z.array(WorkflowActionItemSchema).optional().describe('Workflow actions'),
})
.meta({ id: 'WorkflowUpdateDto' });
@ApiPropertyOptional({ description: 'Workflow filters' })
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
@Optional()
filters?: WorkflowFilterItemDto[];
const WorkflowFilterResponseSchema = z
.object({
id: z.string().describe('Filter ID'),
workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(),
order: z.number().describe('Filter order'),
})
.meta({ id: 'WorkflowFilterResponseDto' });
@ApiPropertyOptional({ description: 'Workflow actions' })
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
@Optional()
actions?: WorkflowActionItemDto[];
}
const WorkflowActionResponseSchema = z
.object({
id: z.string().describe('Action ID'),
workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(),
order: z.number().describe('Action order'),
})
.meta({ id: 'WorkflowActionResponseDto' });
export class WorkflowResponseDto {
@ApiProperty({ description: 'Workflow ID' })
id!: string;
@ApiProperty({ description: 'Owner user ID' })
ownerId!: string;
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' })
triggerType!: PluginTriggerType;
@ApiProperty({ description: 'Workflow name' })
name!: string | null;
@ApiProperty({ description: 'Workflow description' })
description!: string;
@ApiProperty({ description: 'Creation date' })
createdAt!: string;
@ApiProperty({ description: 'Workflow enabled' })
enabled!: boolean;
@ApiProperty({ description: 'Workflow filters' })
filters!: WorkflowFilterResponseDto[];
@ApiProperty({ description: 'Workflow actions' })
actions!: WorkflowActionResponseDto[];
}
const WorkflowResponseSchema = z
.object({
id: z.string().describe('Workflow ID'),
ownerId: z.string().describe('Owner user ID'),
triggerType: PluginTriggerTypeSchema,
name: z.string().nullable().describe('Workflow name'),
description: z.string().describe('Workflow description'),
createdAt: z.string().describe('Creation date'),
enabled: z.boolean().describe('Workflow enabled'),
filters: z.array(WorkflowFilterResponseSchema).describe('Workflow filters'),
actions: z.array(WorkflowActionResponseSchema).describe('Workflow actions'),
})
.meta({ id: 'WorkflowResponseDto' });
export class WorkflowFilterResponseDto {
@ApiProperty({ description: 'Filter ID' })
id!: string;
@ApiProperty({ description: 'Workflow ID' })
workflowId!: string;
@ApiProperty({ description: 'Plugin filter ID' })
pluginFilterId!: string;
@ApiProperty({ description: 'Filter configuration' })
filterConfig!: FilterConfig | null;
@ApiProperty({ description: 'Filter order', type: 'number' })
order!: number;
}
export class WorkflowActionResponseDto {
@ApiProperty({ description: 'Action ID' })
id!: string;
@ApiProperty({ description: 'Workflow ID' })
workflowId!: string;
@ApiProperty({ description: 'Plugin action ID' })
pluginActionId!: string;
@ApiProperty({ description: 'Action configuration' })
actionConfig!: ActionConfig | null;
@ApiProperty({ description: 'Action order', type: 'number' })
order!: number;
}
export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {}
export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {}
export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {}
class WorkflowFilterResponseDto extends createZodDto(WorkflowFilterResponseSchema) {}
class WorkflowActionResponseDto extends createZodDto(WorkflowActionResponseSchema) {}
export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto {
return {

View File

@ -1,8 +1,12 @@
import * as z from 'zod';
export enum AuthType {
Password = 'password',
OAuth = 'oauth',
}
export const AuthTypeSchema = z.enum(AuthType).describe('Auth type').meta({ id: 'AuthType' });
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
@ -13,6 +17,8 @@ export enum ImmichCookie {
OAuthCodeVerifier = 'immich_oauth_code_verifier',
}
export const ImmichCookieSchema = z.enum(ImmichCookie).describe('Immich cookie').meta({ id: 'ImmichCookie' });
export enum ImmichHeader {
ApiKey = 'x-api-key',
UserToken = 'x-immich-user-token',
@ -23,6 +29,8 @@ export enum ImmichHeader {
Cid = 'x-immich-cid',
}
export const ImmichHeaderSchema = z.enum(ImmichHeader).describe('Immich header').meta({ id: 'ImmichHeader' });
export enum ImmichQuery {
SharedLinkKey = 'key',
SharedLinkSlug = 'slug',
@ -30,6 +38,8 @@ export enum ImmichQuery {
SessionKey = 'sessionKey',
}
export const ImmichQuerySchema = z.enum(ImmichQuery).describe('Immich query').meta({ id: 'ImmichQuery' });
export enum AssetType {
Image = 'IMAGE',
Video = 'VIDEO',
@ -37,6 +47,8 @@ export enum AssetType {
Other = 'OTHER',
}
export const AssetTypeSchema = z.enum(AssetType).describe('Asset type').meta({ id: 'AssetTypeEnum' });
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
@ -48,32 +60,44 @@ export enum AssetFileType {
EncodedVideo = 'encoded_video',
}
export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Asset file type').meta({ id: 'AssetFileType' });
export enum AlbumUserRole {
Editor = 'editor',
Viewer = 'viewer',
}
export const AlbumUserRoleSchema = z.enum(AlbumUserRole).describe('Album user role').meta({ id: 'AlbumUserRole' });
export enum AssetOrder {
Asc = 'asc',
Desc = 'desc',
}
export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' });
export enum DatabaseAction {
Create = 'CREATE',
Update = 'UPDATE',
Delete = 'DELETE',
}
export const DatabaseActionSchema = z.enum(DatabaseAction).describe('Database action').meta({ id: 'DatabaseAction' });
export enum EntityType {
Asset = 'ASSET',
Album = 'ALBUM',
}
export const EntityTypeSchema = z.enum(EntityType).describe('Entity type').meta({ id: 'EntityType' });
export enum MemoryType {
/** pictures taken on this day X years ago */
OnThisDay = 'on_this_day',
}
export const MemoryTypeSchema = z.enum(MemoryType).describe('Memory type').meta({ id: 'MemoryType' });
export enum AssetOrderWithRandom {
// Include existing values
Asc = AssetOrder.Asc,
@ -82,6 +106,11 @@ export enum AssetOrderWithRandom {
Random = 'random',
}
export const AssetOrderWithRandomSchema = z
.enum(AssetOrderWithRandom)
.describe('Sort order')
.meta({ id: 'MemorySearchOrder' });
export enum Permission {
All = 'all',
@ -288,6 +317,8 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export const PermissionSchema = z.enum(Permission).describe('Permission').meta({ id: 'Permission' });
export enum SharedLinkType {
Album = 'ALBUM',
@ -298,6 +329,8 @@ export enum SharedLinkType {
Individual = 'INDIVIDUAL',
}
export const SharedLinkTypeSchema = z.enum(SharedLinkType).describe('Shared link type').meta({ id: 'SharedLinkType' });
export enum StorageFolder {
EncodedVideo = 'encoded-video',
Library = 'library',
@ -307,6 +340,8 @@ export enum StorageFolder {
Backups = 'backups',
}
export const StorageFolderSchema = z.enum(StorageFolder).describe('Storage folder').meta({ id: 'StorageFolder' });
export enum SystemMetadataKey {
MediaLocation = 'MediaLocation',
ReverseGeocodingState = 'reverse-geocoding-state',
@ -320,16 +355,31 @@ export enum SystemMetadataKey {
License = 'license',
}
export const SystemMetadataKeySchema = z
.enum(SystemMetadataKey)
.describe('System metadata key')
.meta({ id: 'SystemMetadataKey' });
export enum UserMetadataKey {
Preferences = 'preferences',
License = 'license',
Onboarding = 'onboarding',
}
export const UserMetadataKeySchema = z
.enum(UserMetadataKey)
.describe('User metadata key')
.meta({ id: 'UserMetadataKey' });
export enum AssetMetadataKey {
MobileApp = 'mobile-app',
}
export const AssetMetadataKeySchema = z
.enum(AssetMetadataKey)
.describe('Asset metadata key')
.meta({ id: 'AssetMetadataKey' });
export enum UserAvatarColor {
Primary = 'primary',
Pink = 'pink',
@ -343,24 +393,35 @@ export enum UserAvatarColor {
Amber = 'amber',
}
export const UserAvatarColorSchema = z
.enum(UserAvatarColor)
.describe('User avatar color')
.meta({ id: 'UserAvatarColor' });
export enum UserStatus {
Active = 'active',
Removing = 'removing',
Deleted = 'deleted',
}
export const UserStatusSchema = z.enum(UserStatus).describe('User status').meta({ id: 'UserStatus' });
export enum AssetStatus {
Active = 'active',
Trashed = 'trashed',
Deleted = 'deleted',
}
export const AssetStatusSchema = z.enum(AssetStatus).describe('Asset status').meta({ id: 'AssetStatus' });
export enum SourceType {
MachineLearning = 'machine-learning',
Exif = 'exif',
Manual = 'manual',
}
export const SourceTypeSchema = z.enum(SourceType).describe('Face detection source type').meta({ id: 'SourceType' });
export enum ManualJobName {
PersonCleanup = 'person-cleanup',
TagCleanup = 'tag-cleanup',
@ -370,19 +431,27 @@ export enum ManualJobName {
BackupDatabase = 'backup-database',
}
export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' });
export enum AssetPathType {
Original = 'original',
EncodedVideo = 'encoded_video',
}
export const AssetPathTypeSchema = z.enum(AssetPathType).describe('Asset path type').meta({ id: 'AssetPathType' });
export enum PersonPathType {
Face = 'face',
}
export const PersonPathTypeSchema = z.enum(PersonPathType).describe('Person path type').meta({ id: 'PersonPathType' });
export enum UserPathType {
Profile = 'profile',
}
export const UserPathTypeSchema = z.enum(UserPathType).describe('User path type').meta({ id: 'UserPathType' });
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy {
@ -393,6 +462,11 @@ export enum TranscodePolicy {
Disabled = 'disabled',
}
export const TranscodePolicySchema = z
.enum(TranscodePolicy)
.describe('Transcode policy')
.meta({ id: 'TranscodePolicy' });
export enum TranscodeTarget {
None = 'NONE',
Audio = 'AUDIO',
@ -400,6 +474,11 @@ export enum TranscodeTarget {
All = 'ALL',
}
export const TranscodeTargetSchema = z
.enum(TranscodeTarget)
.describe('Transcode target')
.meta({ id: 'TranscodeTarget' });
export enum VideoCodec {
H264 = 'h264',
Hevc = 'hevc',
@ -407,6 +486,8 @@ export enum VideoCodec {
Av1 = 'av1',
}
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
export enum AudioCodec {
Mp3 = 'mp3',
Aac = 'aac',
@ -416,6 +497,8 @@ export enum AudioCodec {
PcmS16le = 'pcm_s16le',
}
export const AudioCodecSchema = z.enum(AudioCodec).describe('Target audio codec').meta({ id: 'AudioCodec' });
export enum VideoContainer {
Mov = 'mov',
Mp4 = 'mp4',
@ -423,6 +506,11 @@ export enum VideoContainer {
Webm = 'webm',
}
export const VideoContainerSchema = z
.enum(VideoContainer)
.describe('Accepted video containers')
.meta({ id: 'VideoContainer' });
export enum TranscodeHardwareAcceleration {
Nvenc = 'nvenc',
Qsv = 'qsv',
@ -431,6 +519,11 @@ export enum TranscodeHardwareAcceleration {
Disabled = 'disabled',
}
export const TranscodeHardwareAccelerationSchema = z
.enum(TranscodeHardwareAcceleration)
.describe('Transcode hardware acceleration')
.meta({ id: 'TranscodeHWAccel' });
export enum ToneMapping {
Hable = 'hable',
Mobius = 'mobius',
@ -438,27 +531,40 @@ export enum ToneMapping {
Disabled = 'disabled',
}
export const ToneMappingSchema = z.enum(ToneMapping).describe('Tone mapping').meta({ id: 'ToneMapping' });
export enum CQMode {
Auto = 'auto',
Cqp = 'cqp',
Icq = 'icq',
}
export const CQModeSchema = z.enum(CQMode).describe('CQ mode').meta({ id: 'CQMode' });
export enum Colorspace {
Srgb = 'srgb',
P3 = 'p3',
}
export const ColorspaceSchema = z.enum(Colorspace).describe('Colorspace').meta({ id: 'Colorspace' });
export enum ImageFormat {
Jpeg = 'jpeg',
Webp = 'webp',
}
export const ImageFormatSchema = z.enum(ImageFormat).describe('Image format').meta({ id: 'ImageFormat' });
export enum RawExtractedFormat {
Jpeg = 'jpeg',
Jxl = 'jxl',
}
export const RawExtractedFormatSchema = z
.enum(RawExtractedFormat)
.describe('Raw extracted format')
.meta({ id: 'RawExtractedFormat' });
export enum LogLevel {
Verbose = 'verbose',
Debug = 'debug',
@ -468,11 +574,15 @@ export enum LogLevel {
Fatal = 'fatal',
}
export const LogLevelSchema = z.enum(LogLevel).describe('Log level').meta({ id: 'LogLevel' });
export enum LogFormat {
Console = 'console',
Json = 'json',
}
export const LogFormatSchema = z.enum(LogFormat).describe('Log format').meta({ id: 'LogFormat' });
export enum ApiCustomExtension {
Permission = 'x-immich-permission',
AdminOnly = 'x-immich-admin-only',
@ -480,6 +590,11 @@ export enum ApiCustomExtension {
State = 'x-immich-state',
}
export const ApiCustomExtensionSchema = z
.enum(ApiCustomExtension)
.describe('API custom extension')
.meta({ id: 'ApiCustomExtension' });
export enum MetadataKey {
AuthRoute = 'auth_route',
AdminRoute = 'admin_route',
@ -490,29 +605,42 @@ export enum MetadataKey {
TelemetryEnabled = 'telemetry_enabled',
}
export const MetadataKeySchema = z.enum(MetadataKey).describe('Metadata key').meta({ id: 'MetadataKey' });
export enum RouteKey {
Asset = 'assets',
User = 'users',
}
export const RouteKeySchema = z.enum(RouteKey).describe('Route key').meta({ id: 'RouteKey' });
export enum CacheControl {
PrivateWithCache = 'private_with_cache',
PrivateWithoutCache = 'private_without_cache',
None = 'none',
}
export const CacheControlSchema = z.enum(CacheControl).describe('Cache control').meta({ id: 'CacheControl' });
export enum ImmichEnvironment {
Development = 'development',
Testing = 'testing',
Production = 'production',
}
export const ImmichEnvironmentSchema = z
.enum(ImmichEnvironment)
.describe('Immich environment')
.meta({ id: 'ImmichEnvironment' });
export enum ImmichWorker {
Api = 'api',
Maintenance = 'maintenance',
Microservices = 'microservices',
}
export const ImmichWorkerSchema = z.enum(ImmichWorker).describe('Immich worker').meta({ id: 'ImmichWorker' });
export enum ImmichTelemetry {
Host = 'host',
Api = 'api',
@ -521,6 +649,11 @@ export enum ImmichTelemetry {
Job = 'job',
}
export const ImmichTelemetrySchema = z
.enum(ImmichTelemetry)
.describe('Immich telemetry')
.meta({ id: 'ImmichTelemetry' });
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,
@ -532,6 +665,11 @@ export enum ExifOrientation {
Rotate270CW = 8,
}
export const ExifOrientationSchema = z
.enum(ExifOrientation)
.describe('EXIF orientation')
.meta({ id: 'ExifOrientation' });
export enum DatabaseExtension {
Cube = 'cube',
EarthDistance = 'earthdistance',
@ -540,6 +678,11 @@ export enum DatabaseExtension {
VectorChord = 'vchord',
}
export const DatabaseExtensionSchema = z
.enum(DatabaseExtension)
.describe('Database extension')
.meta({ id: 'DatabaseExtension' });
export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access
DatabaseService = -200,
@ -551,6 +694,11 @@ export enum BootstrapEventPriority {
SystemConfig = 100,
}
export const BootstrapEventPrioritySchema = z
.enum(BootstrapEventPriority)
.describe('Bootstrap event priority')
.meta({ id: 'BootstrapEventPriority' });
export enum QueueName {
ThumbnailGeneration = 'thumbnailGeneration',
MetadataExtraction = 'metadataExtraction',
@ -572,6 +720,8 @@ export enum QueueName {
Editor = 'editor',
}
export const QueueNameSchema = z.enum(QueueName).describe('Queue name').meta({ id: 'QueueName' });
export enum QueueJobStatus {
Active = 'active',
Failed = 'failed',
@ -581,6 +731,8 @@ export enum QueueJobStatus {
Paused = 'paused',
}
export const QueueJobStatusSchema = z.enum(QueueJobStatus).describe('Queue job status').meta({ id: 'QueueJobStatus' });
export enum JobName {
AssetDelete = 'AssetDelete',
AssetDeleteCheck = 'AssetDeleteCheck',
@ -661,6 +813,8 @@ export enum JobName {
WorkflowRun = 'WorkflowRun',
}
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
export enum QueueCommand {
Start = 'start',
/** @deprecated Use `updateQueue` instead */
@ -673,21 +827,32 @@ export enum QueueCommand {
ClearFailed = 'clear-failed',
}
export const QueueCommandSchema = z
.enum(QueueCommand)
.describe('Queue command to execute')
.meta({ id: 'QueueCommand' });
export enum JobStatus {
Success = 'success',
Failed = 'failed',
Skipped = 'skipped',
}
export const JobStatusSchema = z.enum(JobStatus).describe('Job status').meta({ id: 'JobStatus' });
export enum QueueCleanType {
Failed = 'failed',
}
export const QueueCleanTypeSchema = z.enum(QueueCleanType).describe('Queue clean type').meta({ id: 'QueueCleanType' });
export enum VectorIndex {
Clip = 'clip_index',
Face = 'face_index',
}
export const VectorIndexSchema = z.enum(VectorIndex).describe('Vector index').meta({ id: 'VectorIndex' });
export enum DatabaseLock {
GeodataImport = 100,
Migrations = 200,
@ -704,6 +869,8 @@ export enum DatabaseLock {
MemoryCreation = 777,
}
export const DatabaseLockSchema = z.enum(DatabaseLock).describe('Database lock').meta({ id: 'DatabaseLock' });
export enum MaintenanceAction {
Start = 'start',
End = 'end',
@ -711,10 +878,17 @@ export enum MaintenanceAction {
RestoreDatabase = 'restore_database',
}
export const MaintenanceActionSchema = z
.enum(MaintenanceAction)
.describe('Maintenance action')
.meta({ id: 'MaintenanceAction' });
export enum ExitCode {
AppRestart = 7,
}
export const ExitCodeSchema = z.enum(ExitCode).describe('Exit code').meta({ id: 'ExitCode' });
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
@ -740,6 +914,11 @@ export enum SyncRequestType {
UserMetadataV1 = 'UserMetadataV1',
}
export const SyncRequestTypeSchema = z
.enum(SyncRequestType)
.describe('Sync request type')
.meta({ id: 'SyncRequestType' });
export enum SyncEntityType {
AuthUserV1 = 'AuthUserV1',
@ -808,6 +987,8 @@ export enum SyncEntityType {
SyncCompleteV1 = 'SyncCompleteV1',
}
export const SyncEntityTypeSchema = z.enum(SyncEntityType).describe('Sync entity type').meta({ id: 'SyncEntityType' });
export enum NotificationLevel {
Success = 'success',
Error = 'error',
@ -815,6 +996,11 @@ export enum NotificationLevel {
Info = 'info',
}
export const NotificationLevelSchema = z
.enum(NotificationLevel)
.describe('Notification level')
.meta({ id: 'NotificationLevel' });
export enum NotificationType {
JobFailed = 'JobFailed',
BackupFailed = 'BackupFailed',
@ -824,11 +1010,21 @@ export enum NotificationType {
Custom = 'Custom',
}
export const NotificationTypeSchema = z
.enum(NotificationType)
.describe('Notification type')
.meta({ id: 'NotificationType' });
export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = 'client_secret_post',
ClientSecretBasic = 'client_secret_basic',
}
export const OAuthTokenEndpointAuthMethodSchema = z
.enum(OAuthTokenEndpointAuthMethod)
.describe('OAuth token endpoint auth method')
.meta({ id: 'OAuthTokenEndpointAuthMethod' });
export enum AssetVisibility {
Archive = 'archive',
Timeline = 'timeline',
@ -840,11 +1036,18 @@ export enum AssetVisibility {
Locked = 'locked',
}
export const AssetVisibilitySchema = z
.enum(AssetVisibility)
.describe('Asset visibility')
.meta({ id: 'AssetVisibility' });
export enum CronJob {
LibraryScan = 'LibraryScan',
NightlyJobs = 'NightlyJobs',
}
export const CronJobSchema = z.enum(CronJob).describe('Cron job').meta({ id: 'CronJob' });
export enum ApiTag {
Activities = 'Activities',
Albums = 'Albums',
@ -885,13 +1088,22 @@ export enum ApiTag {
Workflows = 'Workflows',
}
export const ApiTagSchema = z.enum(ApiTag).describe('API tag').meta({ id: 'ApiTag' });
export enum PluginContext {
Asset = 'asset',
Album = 'album',
Person = 'person',
}
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
export enum PluginTriggerType {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export const PluginTriggerTypeSchema = z
.enum(PluginTriggerType)
.describe('Plugin trigger type')
.meta({ id: 'PluginTriggerType' });

View File

@ -1,8 +1,10 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter<Error> {
@ -41,6 +43,19 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
body = { message: body };
}
// handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) {
body = {
message: zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
),
error: 'Bad Request',
};
}
}
return { status, body };
}

View File

@ -318,7 +318,7 @@ export class AssetRepository {
.execute();
}
upsertMetadata(id: string, items: Array<{ key: string; value: object }>) {
upsertMetadata(id: string, items: Array<{ key: string; value: Record<string, unknown> }>) {
if (items.length === 0) {
return [];
}

View File

@ -85,7 +85,7 @@ describe('getEnv', () => {
describe('IMMICH_MEDIA_LOCATION', () => {
it('should throw an error for relative paths', () => {
process.env.IMMICH_MEDIA_LOCATION = './relative/path';
expect(() => getEnv()).toThrowError('IMMICH_MEDIA_LOCATION must be an absolute path');
expect(() => getEnv()).toThrowError('[IMMICH_MEDIA_LOCATION] Must be an absolute path');
});
});
@ -98,7 +98,7 @@ describe('getEnv', () => {
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_EXTERNAL_PLUGINS must be a boolean value');
expect(() => getEnv()).toThrowError('[IMMICH_ALLOW_EXTERNAL_PLUGINS] Invalid option: expected one of');
});
});
@ -111,7 +111,7 @@ describe('getEnv', () => {
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_SETUP = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_SETUP must be a boolean value');
expect(() => getEnv()).toThrowError('[IMMICH_ALLOW_SETUP] Invalid option: expected one of');
});
});
@ -134,7 +134,7 @@ describe('getEnv', () => {
it('should validate DB_SSL_MODE', () => {
process.env.DB_SSL_MODE = 'invalid';
expect(() => getEnv()).toThrowError('DB_SSL_MODE must be one of the following values:');
expect(() => getEnv()).toThrow(/\[DB_SSL_MODE\] Invalid option: expected one of/);
});
it('should accept a valid DB_SSL_MODE', () => {
@ -278,7 +278,7 @@ describe('getEnv', () => {
it('should reject invalid trusted proxies', () => {
process.env.IMMICH_TRUSTED_PROXIES = '10.1';
expect(() => getEnv()).toThrow('IMMICH_TRUSTED_PROXIES must be an ip address, or ip address range');
expect(() => getEnv()).toThrow('[IMMICH_TRUSTED_PROXIES] Must be an ip address or ip address range');
});
});

View File

@ -2,8 +2,6 @@ import { DatabaseConnectionParams } from '@immich/sql-tools';
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { Inject, Injectable, Optional } from '@nestjs/common';
import { QueueOptions } from 'bullmq';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
@ -11,7 +9,7 @@ import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
import { EnvDto } from 'src/dtos/env.dto';
import { EnvSchema } from 'src/dtos/env.dto';
import {
DatabaseExtension,
ImmichEnvironment,
@ -144,15 +142,16 @@ const asSet = <T>(value: string | undefined, defaults: T[]) => {
};
const getEnv = (): EnvData => {
const dto = plainToInstance(EnvDto, process.env);
const errors = validateSync(dto);
if (errors.length > 0) {
const messages = [`Invalid environment variables: `];
for (const error of errors) {
messages.push(` - ${error.property}=${error.value} (${Object.values(error.constraints || {}).join(', ')})`);
const parseResult = EnvSchema.safeParse(process.env);
if (!parseResult.success) {
const messages = ['Invalid environment variables: '];
for (const issue of parseResult.error.issues) {
const path = issue.path.join('.');
messages.push(` - [${path}] ${issue.message}`);
}
throw new Error(messages.join('\n'));
}
const dto = parseResult.data;
const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.Api, ImmichWorker.Microservices]);
const excludedWorkers = asSet(dto.IMMICH_WORKERS_EXCLUDE, []);

View File

@ -35,7 +35,7 @@ export class AssetMetadataTable {
key!: AssetMetadataKey | string;
@Column({ type: 'jsonb' })
value!: object;
value!: Record<string, unknown>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;

View File

@ -43,7 +43,7 @@ export class MemoryTable {
type!: MemoryType;
@Column({ type: 'jsonb' })
data!: object;
data!: Record<string, unknown>;
/** unless set to true, will be automatically deleted in the future */
@Column({ type: 'boolean', default: false })

View File

@ -13,6 +13,7 @@ import { SessionFactory } from 'test/factories/session.factory';
import { UserFactory } from 'test/factories/user.factory';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -209,11 +210,13 @@ describe(AuthService.name, () => {
it('should sign up the admin', async () => {
mocks.user.getAdmin.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue({
...userStub.admin,
...dto,
id: 'admin',
name: 'immich admin',
createdAt: new Date('2021-01-01'),
metadata: [] as UserMetadataItem[],
} as unknown as UserAdmin);
} as UserAdmin);
await expect(sut.adminSignUp(dto)).resolves.toMatchObject({
avatarColor: expect.any(String),

View File

@ -279,6 +279,7 @@ export class LibraryService extends BaseService {
private async validateImportPath(importPath: string): Promise<ValidateLibraryImportPathResponseDto> {
const validation = new ValidateLibraryImportPathResponseDto();
validation.importPath = importPath;
validation.isValid = false;
if (StorageCore.isImmichPath(importPath)) {
validation.message = 'Cannot use media upload folder for external libraries';

View File

@ -1,11 +1,9 @@
import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
import { BadRequestException, Injectable } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { join } from 'node:path';
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { PluginManifestDto, PluginManifestSchema } from 'src/dtos/plugin-manifest.dto';
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
@ -138,14 +136,7 @@ export class PluginService extends BaseService {
private async readAndValidateManifest(manifestPath: string): Promise<PluginManifestDto> {
const content = await this.storageRepository.readTextFile(manifestPath);
const manifestData = JSON.parse(content);
const manifest = plainToInstance(PluginManifestDto, manifestData);
await validateOrReject(manifest, {
whitelist: true,
forbidNonWhitelisted: true,
});
return manifest;
return PluginManifestSchema.parse(manifestData);
}
///////////////////////////////////////////

View File

@ -138,6 +138,12 @@ export class ServerService extends BaseService {
async getStatistics(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();
serverStats.photos ??= 0;
serverStats.videos ??= 0;
serverStats.usage ??= 0;
serverStats.usagePhotos ??= 0;
serverStats.usageVideos ??= 0;
serverStats.usageByUser ??= [];
for (const user of userStats) {
const usage = new UsageByUserDto();

View File

@ -311,9 +311,7 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } }));
await expect(sut.getSystemConfig()).rejects.toThrow(
'library.scan.cronExpression has failed the following constraints: cronValidator',
);
await expect(sut.getSystemConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression');
});
it('should log errors with the config file', async () => {
@ -402,10 +400,26 @@ describe(SystemConfigService.name, () => {
});
const tests = [
{ should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } },
{ should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },
{ should: 'validate enums', config: { ffmpeg: { transcode: 'unknown' } } },
{ should: 'validate required oauth fields', config: { oauth: { enabled: true } } },
{
should: 'validate numbers',
config: { ffmpeg: { crf: 'not-a-number' } },
throws: '[ffmpeg.crf] Invalid input: expected number, received NaN',
},
{
should: 'validate booleans',
config: { oauth: { enabled: 'invalid' } },
throws: '[oauth.enabled] Invalid input: expected boolean, received string',
},
{
should: 'validate enums',
config: { ffmpeg: { transcode: 'unknown' } },
throws: '[ffmpeg.transcode] Invalid option: expected one of',
},
{
should: 'validate required oauth fields',
config: { oauth: { enabled: true } },
check: (c: SystemConfig) => expect(c.oauth.enabled).toBe(true),
},
{ should: 'warn for top level unknown options', warn: true, config: { unknownOption: true } },
{ should: 'warn for nested unknown options', warn: true, config: { ffmpeg: { unknownOption: true } } },
];
@ -415,11 +429,14 @@ describe(SystemConfigService.name, () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.warn) {
if (test.throws) {
await expect(sut.getSystemConfig()).rejects.toThrow(test.throws);
} else if (test.warn) {
await sut.getSystemConfig();
expect(mocks.logger.warn).toHaveBeenCalled();
} else {
await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error);
const config = await sut.getSystemConfig();
test.check!(config);
}
});
}

View File

@ -3,33 +3,54 @@
* Based on JSON Schema Draft 7
*/
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
import * as z from 'zod';
export interface JSONSchemaProperty {
type?: JSONSchemaType | JSONSchemaType[];
description?: string;
default?: any;
enum?: any[];
items?: JSONSchemaProperty;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean | JSONSchemaProperty;
}
const JSONSchemaTypeSchema = z
.enum(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null'])
.meta({ id: 'PluginJsonSchemaType' });
export interface JSONSchema {
type: 'object';
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean;
description?: string;
}
const JSONSchemaPropertySchema = z
.object({
type: JSONSchemaTypeSchema.optional(),
description: z.string().optional(),
default: z.any().optional(),
enum: z.array(z.string()).optional(),
export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
get items() {
return JSONSchemaPropertySchema.optional();
},
export interface FilterConfig {
[key: string]: ConfigValue;
}
get properties() {
return z.record(z.string(), JSONSchemaPropertySchema).optional();
},
export interface ActionConfig {
[key: string]: ConfigValue;
}
required: z.array(z.string()).optional(),
get additionalProperties() {
return z.union([z.boolean(), JSONSchemaPropertySchema]).optional();
},
})
.meta({ id: 'PluginJsonSchemaProperty' });
export type JSONSchemaProperty = z.infer<typeof JSONSchemaPropertySchema>;
export const JSONSchemaSchema = z
.object({
type: JSONSchemaTypeSchema.optional(),
properties: z.record(z.string(), JSONSchemaPropertySchema).optional(),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
description: z.string().optional(),
})
.meta({ id: 'PluginJsonSchema' });
export type JSONSchema = z.infer<typeof JSONSchemaSchema>;
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
const ConfigValueSchema: z.ZodType<ConfigValue> = z.any().meta({ id: 'PluginConfigValue' });
export const FilterConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowFilterConfig' });
export type FilterConfig = z.infer<typeof FilterConfigSchema>;
export const ActionConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowActionConfig' });
export type ActionConfig = z.infer<typeof ActionConfigSchema>;

View File

@ -1,32 +0,0 @@
import { applyDecorators } from '@nestjs/common';
import { ApiPropertyOptions } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsNotEmpty, ValidateNested } from 'class-validator';
import { Property } from 'src/decorators';
import { BBoxDto } from 'src/dtos/bbox.dto';
import { Optional } from 'src/validation';
type BBoxOptions = { optional?: boolean };
export const ValidateBBox = (options: BBoxOptions & ApiPropertyOptions = {}) => {
const { optional, ...apiPropertyOptions } = options;
return applyDecorators(
Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const [west, south, east, north] = value.split(',', 4).map(Number);
return Object.assign(new BBoxDto(), { west, south, east, north });
}),
Type(() => BBoxDto),
ValidateNested(),
Property({
type: 'string',
description: 'Bounding box coordinates as west,south,east,north (WGS84)',
example: '11.075683,49.416711,11.117589,49.454875',
...apiPropertyOptions,
}),
optional ? Optional({}) : IsNotEmpty(),
);
};

View File

@ -1,10 +1,8 @@
import AsyncLock from 'async-lock';
import { instanceToPlain, plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { load as loadYaml } from 'js-yaml';
import * as _ from 'lodash';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { SystemConfigSchema } from 'src/dtos/system-config.dto';
import { DatabaseLock, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@ -101,19 +99,22 @@ const buildConfig = async (repos: RepoDeps) => {
logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`);
}
// validate full config
const instance = plainToInstance(SystemConfigDto, rawConfig);
const errors = await validate(instance);
if (errors.length > 0) {
// validate with Zod schema
const result = SystemConfigSchema.safeParse(rawConfig);
if (!result.success) {
const messages = ['Invalid system config: '];
for (const issue of result.error.issues) {
const path = issue.path.join('.');
messages.push(` - [${path}] ${issue.message}`);
}
if (configFile) {
throw new Error(`Invalid value(s) in file: ${errors}`);
throw new Error(messages.join('\n'));
} else {
logger.error('Validation error', errors);
logger.error('Validation error', messages);
}
}
// return config with class-transform changes
const config = instanceToPlain(instance) as SystemConfig;
const config = (result.success ? result.data : rawConfig) as SystemConfig;
if (config.server.externalDomain.length > 0) {
const domain = new URL(config.server.externalDomain);

View File

@ -1,9 +1,21 @@
import { DateTime } from 'luxon';
/**
* 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>);
};
/**
* 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.
*/
export const asBirthDateString = (x: Date | string | null): string | null => {
return x instanceof Date ? x.toISOString().split('T')[0] : x;
};

View File

@ -12,6 +12,7 @@ import {
SchemaObject,
} from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import _ from 'lodash';
import { cleanupOpenApiDoc } from 'nestjs-zod';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import picomatch from 'picomatch';
@ -158,11 +159,38 @@ const isSchema = (schema: string | ReferenceObject | SchemaObject): schema is Sc
};
const patchOpenAPI = (document: OpenAPIObject) => {
const removeOpenApi30IncompatibleKeys = (target: unknown) => {
if (!target || typeof target !== 'object') {
return;
}
if (Array.isArray(target)) {
for (const item of target) {
removeOpenApi30IncompatibleKeys(item);
}
return;
}
const object = target as Record<string, unknown>;
delete object.propertyNames;
delete object.contentEncoding;
for (const value of Object.values(object)) {
removeOpenApi30IncompatibleKeys(value);
}
};
document.paths = sortKeys(document.paths);
// Allowed in OpenAPI v3.1 (JSON Schema 2020-12), but not in OpenAPI v3.0 (current spec).
removeOpenApi30IncompatibleKeys(document);
if (document.components?.schemas) {
const schemas = document.components.schemas as Record<string, SchemaObject>;
for (const schema of Object.values(schemas)) {
delete (schema as Record<string, unknown>).id;
}
document.components.schemas = sortKeys(schemas);
for (const [schemaName, schema] of Object.entries(schemas)) {
@ -265,6 +293,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
};
const specification = SwaggerModule.createDocument(app, config, options);
const openApiDoc = cleanupOpenApiDoc(specification);
const customOptions: SwaggerCustomOptions = {
swaggerOptions: {
@ -275,12 +304,12 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
customSiteTitle: 'Immich API Documentation',
};
SwaggerModule.setup('doc', app, specification, customOptions);
SwaggerModule.setup('doc', app, openApiDoc, customOptions);
if (write) {
// Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(openApiDoc), null, 2), { encoding: 'utf8' });
}
};

View File

@ -1,92 +1,45 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { DateTime } from 'luxon';
import { IsDateStringFormat, IsNotSiblingOf, MaxDateString, Optional } from 'src/validation';
import { describe } from 'vitest';
import { IsNotSiblingOf } from 'src/validation';
import { describe, expect, it } from 'vitest';
import * as z from 'zod';
describe('Validation', () => {
describe('MaxDateString', () => {
const maxDate = DateTime.fromISO('2000-01-01', { zone: 'utc' });
class MyDto {
@MaxDateString(maxDate)
date!: string;
}
it('passes when date is before maxDate', async () => {
const dto = plainToInstance(MyDto, { date: '1999-12-31' });
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('passes when date is equal to maxDate', async () => {
const dto = plainToInstance(MyDto, { date: '2000-01-01' });
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('fails when date is after maxDate', async () => {
const dto = plainToInstance(MyDto, { date: '2010-01-01' });
await expect(validate(dto)).resolves.toHaveLength(1);
});
});
describe('IsDateStringFormat', () => {
class MyDto {
@IsDateStringFormat('yyyy-MM-dd')
date!: string;
}
it('passes when date is valid', async () => {
const dto = plainToInstance(MyDto, { date: '1999-12-31' });
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('fails when date has invalid format', async () => {
const dto = plainToInstance(MyDto, { date: '2000-01-01T00:00:00Z' });
await expect(validate(dto)).resolves.toHaveLength(1);
});
it('fails when empty string', async () => {
const dto = plainToInstance(MyDto, { date: '' });
await expect(validate(dto)).resolves.toHaveLength(1);
});
it('fails when undefined', async () => {
const dto = plainToInstance(MyDto, {});
await expect(validate(dto)).resolves.toHaveLength(1);
});
});
describe('IsNotSiblingOf', () => {
class MyDto {
@IsNotSiblingOf(['attribute2'])
@Optional()
attribute1?: string;
@IsNotSiblingOf(['attribute1', 'attribute3'])
@Optional()
attribute2?: string;
@IsNotSiblingOf(['attribute2'])
@Optional()
attribute3?: string;
@Optional()
unrelatedAttribute?: string;
}
it('passes when only one attribute is present', async () => {
const dto = plainToInstance(MyDto, { attribute1: 'value1', unrelatedAttribute: 'value2' });
await expect(validate(dto)).resolves.toHaveLength(0);
const MySchemaBase = z.object({
attribute1: z.string().optional(),
attribute2: z.string().optional(),
attribute3: z.string().optional(),
unrelatedAttribute: z.string().optional(),
});
it('fails when colliding attributes are present', async () => {
const dto = plainToInstance(MyDto, { attribute1: 'value1', attribute2: 'value2' });
await expect(validate(dto)).resolves.toHaveLength(2);
const MySchema = MySchemaBase.pipe(IsNotSiblingOf(MySchemaBase, 'attribute1', ['attribute2']))
.pipe(IsNotSiblingOf(MySchemaBase, 'attribute2', ['attribute1', 'attribute3']))
.pipe(IsNotSiblingOf(MySchemaBase, 'attribute3', ['attribute2']));
it('passes when only one attribute is present', () => {
const result = MySchema.safeParse({
attribute1: 'value1',
unrelatedAttribute: 'value2',
});
expect(result.success).toBe(true);
});
it('passes when no colliding attributes are present', async () => {
const dto = plainToInstance(MyDto, { attribute1: 'value1', attribute3: 'value2' });
await expect(validate(dto)).resolves.toHaveLength(0);
it('fails when colliding attributes are present', () => {
const result = MySchema.safeParse({
attribute1: 'value1',
attribute2: 'value2',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('attribute1 cannot exist alongside attribute2');
}
});
it('passes when no colliding attributes are present', () => {
const result = MySchema.safeParse({
attribute1: 'value1',
attribute3: 'value2',
});
expect(result.success).toBe(true);
});
});
});

View File

@ -1,40 +1,62 @@
import {
ArgumentMetadata,
BadRequestException,
FileValidator,
Injectable,
ParseUUIDPipe,
applyDecorators,
} from '@nestjs/common';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsHexColor,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
Validate,
ValidateBy,
ValidateIf,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
buildMessage,
isDateString,
isDefined,
} from 'class-validator';
import { CronJob } from 'cron';
import { DateTime } from 'luxon';
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import { Property, PropertyOptions } from 'src/decorators';
import { isIP, isIPRange } from 'validator';
import * as z from 'zod';
export type IsIPRangeOptions = { requireCIDR?: boolean };
function isIPOrRange(value: string, options?: IsIPRangeOptions): boolean {
const { requireCIDR = true } = options ?? {};
if (isIPRange(value)) {
return true;
}
if (!requireCIDR && isIP(value)) {
return true;
}
return false;
}
/**
* Zod schema that validates an array of strings as IP addresses or IP/CIDR ranges.
* When requireCIDR is true (default), plain IPs are rejected; only CIDR ranges are allowed.
*
* @example
* z.string().optional().transform(...).pipe(IsIPRange())
* @example
* z.string().optional().transform(...).pipe(IsIPRange({ requireCIDR: false }))
*/
export function IsIPRange(options?: IsIPRangeOptions) {
return z
.array(z.string())
.refine((arr) => arr.every((item) => isIPOrRange(item, options)), 'Must be an ip address or ip address range');
}
/**
* Zod schema that validates sibling-exclusion for object schemas.
* Validation passes when the target property is missing, or when none of the sibling properties are present.
* Use with .pipe() like IsIPRange.
*
* @example
* const Schema = z.object({ a: z.string().optional(), b: z.string().optional() });
* Schema.pipe(IsNotSiblingOf(Schema, 'a', ['b']));
*/
export function IsNotSiblingOf<
TSchema extends z.ZodObject<z.ZodRawShape>,
TKey extends z.infer<ReturnType<TSchema['keyof']>> & keyof z.infer<TSchema>,
>(_schema: TSchema, property: TKey, siblings: TKey[]) {
type T = z.infer<TSchema>;
const message = `${String(property)} cannot exist alongside ${siblings.join(' or ')}`;
return z.custom<T>().refine(
(data) => {
if (data[property] === undefined) {
return true;
}
return siblings.every((sibling) => data[sibling] === undefined);
},
{ message },
);
}
@Injectable()
export class ParseMeUUIDPipe extends ParseUUIDPipe {
@ -66,386 +88,163 @@ export class FileNotEmptyValidator extends FileValidator {
}
}
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
export const ValidateUUID = (options?: UUIDOptions & PropertyOptions) => {
const { optional, each, nullable, ...apiPropertyOptions } = {
optional: false,
each: false,
nullable: false,
...options,
};
return applyDecorators(
IsUUID('4', { each }),
Property({ format: 'uuid', ...apiPropertyOptions }),
optional ? Optional({ nullable }) : IsNotEmpty(),
each ? IsArray() : IsString(),
);
};
const UUIDParamSchema = z.object({
id: z.uuidv4(),
});
export function IsAxisAlignedRotation() {
return ValidateBy(
{
name: 'isAxisAlignedRotation',
validator: {
validate(value: any) {
return [0, 90, 180, 270].includes(value);
},
defaultMessage: buildMessage(
(eachPrefix) => eachPrefix + '$property must be one of the following values: 0, 90, 180, 270',
{},
),
},
},
{},
);
}
export class UUIDParamDto extends createZodDto(UUIDParamSchema) {}
@ValidatorConstraint({ name: 'uniqueEditActions' })
class UniqueEditActionsValidator implements ValidatorConstraintInterface {
validate(edits: { action: string; parameters?: unknown }[]): boolean {
if (!Array.isArray(edits)) {
return true;
}
const UUIDAssetIDParamSchema = z.object({
id: z.uuidv4(),
assetId: z.uuidv4(),
});
const actionSet = new Set<string>();
for (const edit of edits) {
const key = edit.action === 'mirror' ? `${edit.action}-${JSON.stringify(edit.parameters)}` : edit.action;
if (actionSet.has(key)) {
return false;
}
actionSet.add(key);
}
return true;
}
export class UUIDAssetIDParamDto extends createZodDto(UUIDAssetIDParamSchema) {}
defaultMessage(): string {
return 'Duplicate edit actions are not allowed';
}
}
const FilenameParamSchema = z.object({
filename: z.string().regex(/^[a-zA-Z0-9_\-.]+$/, {
error: 'Filename contains invalid characters',
}),
});
export const IsUniqueEditActions = () => Validate(UniqueEditActionsValidator);
export class UUIDParamDto {
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
id!: string;
}
export class UUIDAssetIDParamDto {
@ValidateUUID()
id!: string;
@ValidateUUID()
assetId!: string;
}
export class FilenameParamDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ format: 'string' })
@Matches(/^[a-zA-Z0-9_\-.]+$/, {
message: 'Filename contains invalid characters',
})
filename!: string;
}
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {
optional: false,
nullable: false,
emptyToNull: false,
...options,
};
const decorators = [
IsString(),
IsNotEmpty(),
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
ApiProperty({ example: '123456', ...apiPropertyOptions }),
];
if (optional) {
decorators.push(Optional({ nullable, emptyToNull }));
}
return applyDecorators(...decorators);
};
export interface OptionalOptions {
nullable?: boolean;
/** convert empty strings to null */
emptyToNull?: boolean;
}
/**
* Checks if value is missing and if so, ignores all validators.
*
* @param validationOptions {@link OptionalOptions}
*
* @see IsOptional exported from `class-validator.
*/
// https://stackoverflow.com/a/71353929
export function Optional({ nullable, emptyToNull, ...validationOptions }: OptionalOptions = {}) {
const decorators: PropertyDecorator[] = [];
if (nullable === true) {
decorators.push(IsOptional(validationOptions));
} else {
decorators.push(ValidateIf((object: any, v: any) => v !== undefined, validationOptions));
}
if (emptyToNull) {
decorators.push(Transform(({ value }) => (value === '' ? null : value)));
}
return applyDecorators(...decorators);
}
export function IsNotSiblingOf(siblings: string[], validationOptions?: ValidationOptions) {
return ValidateBy(
{
name: 'isNotSiblingOf',
constraints: siblings,
validator: {
validate(value: any, args: ValidationArguments) {
if (!isDefined(value)) {
return true;
}
return args.constraints.filter((prop) => isDefined((args.object as any)[prop])).length === 0;
},
defaultMessage: (args: ValidationArguments) => {
return `${args.property} cannot exist alongside any of the following properties: ${args.constraints.join(', ')}`;
},
},
},
validationOptions,
);
}
export const ValidateHexColor = () => {
const decorators = [
IsHexColor(),
Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)),
];
return applyDecorators(...decorators);
};
type DateOptions = OptionalOptions & { optional?: boolean; format?: 'date' | 'date-time' };
export const ValidateDate = (options?: DateOptions & PropertyOptions) => {
const {
optional,
nullable = false,
emptyToNull = false,
format = 'date-time',
...apiPropertyOptions
} = options || {};
return applyDecorators(
Property({ format, ...apiPropertyOptions }),
IsDate(),
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
Transform(({ key, value }) => {
if (value === null || value === undefined) {
return value;
}
if (!isDateString(value)) {
throw new BadRequestException(`${key} must be a date string`);
}
return new Date(value as string);
}),
);
};
type StringOptions = OptionalOptions & { optional?: boolean; trim?: boolean };
export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => {
const { optional, nullable, emptyToNull, trim, ...apiPropertyOptions } = options || {};
const decorators = [
ApiProperty(apiPropertyOptions),
IsString(),
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
];
if (trim) {
decorators.push(Transform(({ value }: { value: string }) => value?.trim()));
}
return applyDecorators(...decorators);
};
type BooleanOptions = OptionalOptions & { optional?: boolean };
export const ValidateBoolean = (options?: BooleanOptions & PropertyOptions) => {
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = options || {};
const decorators = [
Property(apiPropertyOptions),
IsBoolean(),
Transform(({ value }) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
return false;
}
return value;
}),
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
];
return applyDecorators(...decorators);
};
type EnumOptions<T> = {
enum: T;
name: string;
each?: boolean;
optional?: boolean;
nullable?: boolean;
default?: T[keyof T];
description?: string;
};
export const ValidateEnum = <T extends object>({
name,
enum: value,
each,
optional,
nullable,
default: defaultValue,
description,
}: EnumOptions<T>) => {
return applyDecorators(
optional ? Optional({ nullable }) : IsNotEmpty(),
IsEnum(value, { each }),
ApiProperty({ enumName: name, enum: value, isArray: each, default: defaultValue, description }),
);
};
@ValidatorConstraint({ name: 'cronValidator' })
class CronValidator implements ValidatorConstraintInterface {
validate(expression: string): boolean {
try {
new CronJob(expression, () => {});
return true;
} catch {
return false;
}
}
}
export const IsCronExpression = () => Validate(CronValidator, { message: 'Invalid cron expression' });
type IValue = { value: unknown };
export const toEmail = ({ value }: IValue) => (typeof value === 'string' ? value.toLowerCase() : value);
export const toSanitized = ({ value }: IValue) => {
const input = typeof value === 'string' ? value : '';
return sanitize(input.replaceAll('.', ''));
};
export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}
export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
return Number.isInteger(value) && value >= min && value <= max;
};
export function isDateStringFormat(value: unknown, format: string) {
if (typeof value !== 'string') {
return false;
}
return DateTime.fromFormat(value, format, { zone: 'utc' }).isValid;
}
/**
* Unified email validation
* Converts email strings to lowercase and validates against HTML5 email regex
* @docs https://zod.dev/api?id=email
*/
export const toEmail = z
.email({
pattern: z.regexes.html5Email,
error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`,
})
.transform((val) => val.toLowerCase());
export function IsDateStringFormat(format: string, validationOptions?: ValidationOptions) {
return ValidateBy(
/**
* Parse ISO 8601 datetime strings to Date objects
* @docs https://zod.dev/api?id=codec
*/
export const isoDatetimeToDate = z
.codec(
z.iso.datetime({
error: (iss) => `Invalid input: expected ISO 8601 datetime string, received ${typeof iss.input}`,
}),
z.date(),
{
name: 'isDateStringFormat',
constraints: [format],
validator: {
validate(value: unknown) {
return isDateStringFormat(value, format);
},
defaultMessage: () => `$property must be a string in the format ${format}`,
},
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString(),
},
validationOptions,
);
}
)
.meta({ example: '2024-01-01T00:00:00.000Z' });
function maxDate(date: DateTime, maxDate: DateTime | (() => DateTime)) {
return date <= (maxDate instanceof DateTime ? maxDate : maxDate());
}
export function MaxDateString(
date: DateTime | (() => DateTime),
validationOptions?: ValidationOptions,
): PropertyDecorator {
return ValidateBy(
/**
* Parse ISO date strings to Date objects
* @docs https://zod.dev/api?id=codec
*/
export const isoDateToDate = z
.codec(
z.iso.date({
error: (iss) => `Invalid input: expected ISO date string (YYYY-MM-DD), received ${typeof iss.input}`,
}),
z.date(),
{
name: 'maxDateString',
constraints: [date],
validator: {
validate: (value, args) => {
const date = DateTime.fromISO(value, { zone: 'utc' });
return maxDate(date, args?.constraints[0]);
},
defaultMessage: buildMessage(
(eachPrefix) => 'maximal allowed date for ' + eachPrefix + '$property is $constraint1',
validationOptions,
),
},
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString().slice(0, 10),
},
validationOptions,
);
}
)
.meta({ example: '2024-01-01' });
type IsIPRangeOptions = { requireCIDR?: boolean };
export function IsIPRange(options: IsIPRangeOptions, validationOptions?: ValidationOptions): PropertyDecorator {
const { requireCIDR } = { requireCIDR: true, ...options };
export const isValidTime = z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Invalid input: expected string in HH:mm format, received string');
return ValidateBy(
{
name: 'isIPRange',
validator: {
validate: (value): boolean => {
if (isIPRange(value)) {
return true;
}
/**
* Latitude in range [-90, 90]. Reuse for body or query params.
*
* @example
* // Regular (body): optional coordinates
* latitudeSchema.optional().describe('Latitude coordinate')
*
* @example
* // Pipe (query): coerce string to number then validate range
* z.coerce.number().pipe(latitudeSchema).describe('Latitude (-90 to 90)')
*/
export const latitudeSchema = z.number().min(-90).max(90);
if (!requireCIDR && isIP(value)) {
return true;
}
/**
* Longitude in range [-180, 180]. Reuse for body or query params.
*
* @example
* // Regular (body): optional coordinates
* longitudeSchema.optional().describe('Longitude coordinate')
*
* @example
* // Pipe (query): coerce string to number then validate range
* z.coerce.number().pipe(longitudeSchema).describe('Longitude (-180 to 180)')
*/
export const longitudeSchema = z.number().min(-180).max(180);
return false;
},
defaultMessage: buildMessage(
(eachPrefix) => eachPrefix + '$property must be an ip address, or ip address range',
validationOptions,
),
},
},
validationOptions,
);
}
/**
* Parse string to boolean
* This should be used for boolean query parameters and path parameters, but not for boolean request body parameters, as the first are always string.
* We don't use z.coerce.boolean() as any truthy value is considered true
* z.stringbool() is a more robust way to parse strings to booleans as it lets you specify the truthy and falsy values and the case sensitivity.
* @docs https://zod.dev/api?id=coercion
* @docs https://zod.dev/api?id=stringbool
*/
export const stringToBool = z
.stringbool({ truthy: ['true'], falsy: ['false'], case: 'sensitive' })
.meta({ type: 'boolean' });
@ValidatorConstraint({ name: 'isGreaterThanOrEqualTo' })
export class IsGreaterThanOrEqualToConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments) {
const relatedPropertyName = args.constraints?.[0] as string;
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName];
if (!Number.isFinite(value) || !Number.isFinite(relatedValue)) {
return true;
/**
* Parse JSON strings from multipart/form-data
*/
export const JsonParsed = z.transform((val, ctx) => {
if (typeof val === 'string') {
try {
return JSON.parse(val);
} catch {
ctx.issues.push({
code: 'custom',
message: `Invalid input: expected JSON string, received ${typeof val}`,
input: val,
});
return z.NEVER;
}
return Number(value) >= Number(relatedValue);
}
return val;
});
defaultMessage(args: ValidationArguments) {
const relatedPropertyName = args.constraints?.[0] as string;
return `${args.property} must be greater than or equal to ${relatedPropertyName}`;
}
}
/**
* Hex color validation and normalization.
* Accepts formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (with or without # prefix).
* Normalizes output to always include the # prefix.
*
* @example
* hexColor.optional()
*/
const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
export const hexColor = z
.string()
.regex(hexColorRegex)
.transform((val) => (val.startsWith('#') ? val : `#${val}`));
export const IsGreaterThanOrEqualTo = (property: string, validationOptions?: ValidationOptions) => {
return Validate(IsGreaterThanOrEqualToConstraint, [property], validationOptions);
};
/**
* 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('.', '')));

View File

@ -1,12 +1,14 @@
import { createPostgres, DatabaseConnectionParams } from '@immich/sql-tools';
import { CallHandler, ExecutionContext, Provider, ValidationPipe } from '@nestjs/common';
import { APP_GUARD, APP_PIPE } from '@nestjs/core';
import { CallHandler, ExecutionContext, Provider } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { Test } from '@nestjs/testing';
import { ClassConstructor } from 'class-transformer';
import { NextFunction } from 'express';
import { Kysely } from 'kysely';
import multer from 'multer';
import { ClsService } from 'nestjs-cls';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Duplex, Readable, Writable } from 'node:stream';
import { PNG } from 'pngjs';
@ -14,6 +16,7 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { AuthGuard } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@ -113,9 +116,12 @@ export const controllerSetup = async (controller: ClassConstructor<unknown>, pro
const moduleRef = await Test.createTestingModule({
controllers: [controller],
providers: [
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useClass: ZodValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: LoggingRepository, useValue: LoggingRepository.create() },
{ provide: ClsService, useValue: { getId: vi.fn() } },
{ provide: AuthService, useValue: { authenticate: vi.fn() } },
...providers,
],