Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-04-22 18:08:10 +02:00
1565 changed files with 43096 additions and 79581 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import { json, urlencoded } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmetMiddleware from 'helmet';
@@ -56,6 +56,7 @@ export async function configureExpress(
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
app.use(urlencoded({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
+4 -2
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';
@@ -43,7 +44,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 },
];
+1 -2
View File
@@ -3,7 +3,6 @@ import { INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule';
import { Test } from '@nestjs/testing';
import { ClassConstructor } from 'class-transformer';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
@@ -44,7 +43,7 @@ export class SqlLogger {
const reflector = new Reflector();
type Repository = ClassConstructor<any>;
type Repository = new (...args: any[]) => any;
type SqlGeneratorOptions = { targetDir: string };
class SqlGenerator {
+7 -1
View File
@@ -104,13 +104,16 @@ export type SystemConfig = {
defaultStorageQuota: number | null;
enabled: boolean;
issuerUrl: string;
endSessionEndpoint: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
prompt: string;
scope: string;
signingAlgorithm: string;
profileSigningAlgorithm: string;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
timeout: number;
allowInsecureRequests: boolean;
storageLabelClaim: string;
storageQuotaClaim: string;
roleClaim: string;
@@ -247,7 +250,7 @@ export const defaults = Object.freeze<SystemConfig>({
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
availabilityChecks: {
enabled: true,
timeout: Number(process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT) || 2000,
timeout: 2000,
interval: 30_000,
},
clip: {
@@ -295,8 +298,10 @@ export const defaults = Object.freeze<SystemConfig>({
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
@@ -305,6 +310,7 @@ export const defaults = Object.freeze<SystemConfig>({
roleClaim: 'immich_role',
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost,
timeout: 30_000,
allowInsecureRequests: false,
},
passwordLogin: {
enabled: true,
-4
View File
@@ -1,4 +1,3 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
@@ -52,9 +51,6 @@ const packageFile = join(basePath, '..', 'package.json');
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const citiesFile = 'cities500.txt';
export const reverseGeocodeMaxDistance = 25_000;
@@ -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']));
});
});
});
@@ -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']));
});
});
+16 -9
View File
@@ -3,7 +3,6 @@ import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
@@ -15,6 +14,7 @@ import {
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
@@ -65,12 +65,8 @@ export class AlbumController {
description: 'Retrieve information about a specific album by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AlbumInfoDto,
): Promise<AlbumResponseDto> {
return this.service.get(auth, id, dto);
getAlbumInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AlbumResponseDto> {
return this.service.get(auth, id);
}
@Patch(':id')
@@ -102,8 +98,19 @@ export class AlbumController {
return this.service.delete(auth, id);
}
@Authenticated({ permission: Permission.AlbumRead, sharedLink: true })
@Get(':id/map-markers')
@Endpoint({
summary: 'Retrieve album map markers',
description: 'Retrieve map marker information for a specific album by its ID.',
history: new HistoryBuilder().added('v3'),
})
getAlbumMapMarkers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(auth, id);
}
@Put(':id/assets')
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
@Authenticated({ permission: Permission.AlbumAssetCreate })
@Endpoint({
summary: 'Add assets to an album',
description: 'Add multiple assets to a specific album by its ID.',
@@ -118,7 +125,7 @@ export class AlbumController {
}
@Put('assets')
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
@Authenticated({ permission: Permission.AlbumAssetCreate })
@Endpoint({
summary: 'Add assets to albums',
description: 'Send a list of asset IDs and album IDs to add each asset to each album.',
@@ -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']));
});
});
});
+7 -7
View File
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { ApiKeyCreateDto, ApiKeyCreateResponseDto, ApiKeyResponseDto, ApiKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
@@ -20,7 +20,7 @@ export class ApiKeyController {
description: 'Creates a new API key. It will be limited to the permissions specified.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
createApiKey(@Auth() auth: AuthDto, @Body() dto: ApiKeyCreateDto): Promise<ApiKeyCreateResponseDto> {
return this.service.create(auth, dto);
}
@@ -31,7 +31,7 @@ export class ApiKeyController {
description: 'Retrieve all API keys of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getApiKeys(@Auth() auth: AuthDto): Promise<APIKeyResponseDto[]> {
getApiKeys(@Auth() auth: AuthDto): Promise<ApiKeyResponseDto[]> {
return this.service.getAll(auth);
}
@@ -42,7 +42,7 @@ export class ApiKeyController {
description: 'Retrieve the API key that is used to access this endpoint.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
async getMyApiKey(@Auth() auth: AuthDto): Promise<ApiKeyResponseDto> {
return this.service.getMine(auth);
}
@@ -53,7 +53,7 @@ export class ApiKeyController {
description: 'Retrieve an API key by its ID. The current user must own this API key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ApiKeyResponseDto> {
return this.service.getById(auth, id);
}
@@ -67,8 +67,8 @@ export class ApiKeyController {
updateApiKey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: APIKeyUpdateDto,
): Promise<APIKeyResponseDto> {
@Body() dto: ApiKeyUpdateDto,
): Promise<ApiKeyResponseDto> {
return this.service.update(auth, id, dto);
}
@@ -9,12 +9,9 @@ import { automock, ControllerContext, controllerSetup, mockBaseService } from 't
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
const dto: Record<string, any> = {
deviceAssetId: 'example-image',
deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
isFavorite: 'false',
duration: '0:00:00.000000',
};
const omit = options?.omit;
@@ -82,29 +79,11 @@ describe(AssetMediaController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
});
it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.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(['[metadata] Invalid input: expected JSON string, received string']),
);
});
it('should require `deviceId`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.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']));
});
it('should require `fileCreatedAt`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
@@ -112,7 +91,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 +104,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 +116,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 +128,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')]),
);
});
@@ -156,11 +141,16 @@ describe(AssetMediaController.name, () => {
});
// TODO figure out how to deal with `sendFile`
describe.skip('GET /assets/:id/thumbnail', () => {
it('should be an authenticated route', async () => {
describe('GET /assets/:id/thumbnail', () => {
it.skip('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should redirect if size=original is requested', async () => {
const { status } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail?size=original`);
expect(status).toBe(302);
});
});
});
});
@@ -8,7 +8,6 @@ import {
Param,
ParseFilePipe,
Post,
Put,
Query,
Req,
Res,
@@ -22,16 +21,12 @@ import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
AssetMediaStatus,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto';
import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -112,36 +107,6 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
}
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 200,
description: 'Asset replaced successfully',
type: AssetMediaResponseDto,
})
@Endpoint({
summary: 'Replace asset',
description: 'Replace the asset with new file, without changing its id.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'copyAsset' }),
})
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaReplaceDto,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}
return responseDto;
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@@ -159,6 +124,16 @@ export class AssetMediaController {
@Res() res: Response,
@Next() next: NextFunction,
) {
if (dto.size === AssetMediaSize.Original) {
this.logger.deprecate(
'Calling the thumbnail endpoint with size=original is deprecated. Use the :id/original endpoint instead',
);
const [_, reqSearch] = req.url.split('?');
const redirSearchParams = new URLSearchParams(reqSearch);
redirSearchParams.delete('size');
return res.redirect('original' + '?' + redirSearchParams.toString());
}
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
if (viewThumbnailRes instanceof ImmichFileResponse) {
@@ -202,21 +177,6 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
}
@Post('exist')
@Authenticated({ permission: Permission.AssetUpload })
@Endpoint({
summary: 'Check existing assets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
@HttpCode(HttpStatus.OK)
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.service.checkExistingAssets(auth, dto);
}
@Post('bulk-upload-check')
@Authenticated({ permission: Permission.AssetUpload })
@Endpoint({
+32 -34
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 () => {
@@ -238,21 +245,6 @@ describe(AssetController.name, () => {
});
});
describe('GET /assets/random', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/random`);
expect(ctx.authenticate).toHaveBeenCalled();
});
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']),
);
});
});
describe('GET /assets/:id/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
@@ -269,13 +261,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 +276,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 +286,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 +326,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 +376,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 +399,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 +413,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 +426,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']));
});
});
});
@@ -15,8 +15,6 @@ import {
AssetMetadataUpsertDto,
AssetStatsDto,
AssetStatsResponseDto,
DeviceIdDto,
RandomAssetsDto,
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -32,28 +30,6 @@ import { UUIDParamDto } from 'src/validation';
export class AssetController {
constructor(private service: AssetService) {}
@Get('random')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Get random assets',
description: 'Retrieve a specified number of random assets for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'searchAssets' }),
})
getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
return this.service.getRandom(auth, dto.count ?? 1);
}
@Get('/device/:deviceId')
@Endpoint({
summary: 'Retrieve assets by device ID',
description: 'Get all asset of a device that are in the database, ID only.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
@Authenticated()
getAllUserAssetsByDeviceId(@Auth() auth: AuthDto, @Param() { deviceId }: DeviceIdDto) {
return this.service.getUserAssetsByDeviceId(auth, deviceId);
}
@Get('statistics')
@Authenticated({ permission: Permission.AssetStatistics })
@Endpoint({
+8 -10
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}$/`]));
});
});
@@ -41,7 +41,7 @@ describe(DuplicateController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/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']));
});
});
});
@@ -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();
});
@@ -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,13 @@ 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']));
});
it('should require at least one field', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['At least one field must be provided']));
});
});
@@ -116,7 +120,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 +128,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 +141,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 +149,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']));
});
});
});
@@ -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']));
});
});
+15 -1
View File
@@ -1,11 +1,12 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AuthDto,
LoginResponseDto,
OAuthAuthorizeResponseDto,
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
} from 'src/dtos/auth.dto';
@@ -112,4 +113,17 @@ export class OAuthController {
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth);
}
@Post('backchannel-logout')
@HttpCode(HttpStatus.OK)
@ApiConsumes('application/x-www-form-urlencoded')
@Endpoint({
summary: 'Backchannel OAuth logout',
description:
'Logout the OAuth account and invalidate the session specified by the sid claim or all sessions if the sid claim is not present.',
history: new HistoryBuilder().added('v2'),
})
async logoutOAuth(@Body() dto: OAuthBackchannelLogoutDto): Promise<void> {
return this.service.backchannelLogout(dto);
}
}
@@ -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']));
});
});
});
@@ -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 () => {
@@ -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')]));
});
});
});
@@ -11,7 +11,6 @@ import {
ServerPingResponse,
ServerStatsResponseDto,
ServerStorageResponseDto,
ServerThemeDto,
ServerVersionHistoryResponseDto,
ServerVersionResponseDto,
} from 'src/dtos/server.dto';
@@ -104,16 +103,6 @@ export class ServerController {
return this.service.getFeatures();
}
@Get('theme')
@Endpoint({
summary: 'Get theme',
description: 'Retrieve the custom CSS, if existent.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getTheme(): Promise<ServerThemeDto> {
return this.service.getTheme();
}
@Get('config')
@Endpoint({
summary: 'Get config',
@@ -23,7 +23,6 @@ import {
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkLoginDto,
SharedLinkPasswordDto,
SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto';
@@ -96,21 +95,7 @@ export class SharedLinkController {
description: 'Retrieve the current shared link associated with authentication method.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getMySharedLink(
@Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> {
if (dto.password) {
this.logger.deprecate(
'Passing shared link password via query parameters is deprecated and will be removed in the next major release. Please use POST /shared-links/login instead.',
);
return this.sharedLinkLogin(auth, { password: dto.password }, req, res, loginDetails);
}
getMySharedLink(@Auth() auth: AuthDto, @Req() req: Request): Promise<SharedLinkResponseDto> {
return this.service.getMine(auth, getAuthTokens(req.cookies));
}
@@ -164,7 +149,7 @@ export class SharedLinkController {
}
@Put(':id/assets')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.SharedLinkUpdate })
@Endpoint({
summary: 'Add assets to a shared link',
description:
@@ -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();
});
});
+1 -34
View File
@@ -2,17 +2,8 @@ import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetDeltaSyncDto,
AssetDeltaSyncResponseDto,
AssetFullSyncDto,
SyncAckDeleteDto,
SyncAckDto,
SyncAckSetDto,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { SyncAckDeleteDto, SyncAckDto, SyncAckSetDto, SyncStreamDto } from 'src/dtos/sync.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
@@ -26,30 +17,6 @@ export class SyncController {
private errorService: GlobalExceptionFilter,
) {}
@Post('full-sync')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get full sync for user',
description: 'Retrieve all assets for a full synchronization for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getFullSync(auth, dto);
}
@Post('delta-sync')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get delta sync for user',
description: 'Retrieve changed assets since the last sync for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getDeltaSync(auth, dto);
}
@Post('stream')
@Authenticated({ permission: Permission.SyncStream })
@Header('Content-Type', 'application/jsonlines+json')
@@ -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']),
);
});
});
});
@@ -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']));
});
});
@@ -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', () => {
@@ -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 () => {
+1 -1
View File
@@ -64,7 +64,7 @@ export class UserController {
@Authenticated({ permission: Permission.UserUpdate })
@Endpoint({
summary: 'Update current user',
description: 'Update the current user making teh API request.',
description: 'Update the current user making the API request.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
+2 -9
View File
@@ -18,7 +18,7 @@ import {
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
@@ -104,7 +104,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>[];
@@ -114,8 +114,6 @@ export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
isExternal: boolean;
@@ -197,7 +195,6 @@ export type SharedLink = {
};
export type Album = Selectable<AlbumTable> & {
owner: ShallowDehydrateObject<User>;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
};
@@ -279,8 +276,6 @@ export type AssetFace = {
isVisible: boolean;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
methodName: string;
title: string;
@@ -333,8 +328,6 @@ export const columns = {
'asset.id',
'asset.checksum',
'asset.checksumAlgorithm',
'asset.deviceAssetId',
'asset.deviceId',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.isExternal',
+1 -12
View File
@@ -1,6 +1,6 @@
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiOperationOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
@@ -172,17 +172,6 @@ export const Endpoint = ({ history, ...options }: EndpointOptions) => {
return applyDecorators(...decorators);
};
export type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
export const Property = ({ history, ...options }: PropertyOptions) => {
const extensions = history?.getExtensions() ?? {};
if (history?.isDeprecated()) {
options.deprecated = true;
}
return ApiProperty({ ...options, ...extensions });
};
type HistoryEntry = {
version: string;
state: ApiState | 'Added' | 'Updated';
+58 -60
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 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) {}
+2 -2
View File
@@ -10,13 +10,13 @@ describe('mapAlbum', () => {
.asset({ localDateTime: endDate }, (builder) => builder.exif())
.asset({ localDateTime: startDate }, (builder) => builder.exif())
.build();
const dto = mapAlbum(getForAlbum(album), false);
const dto = mapAlbum(getForAlbum(album));
expect(dto.startDate).toEqual(startDate.toISOString());
expect(dto.endDate).toEqual(endDate.toISOString());
});
it('should not set start and end dates for empty assets', () => {
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
const dto = mapAlbum(getForAlbum(AlbumFactory.create()));
expect(dto.startDate).toBeUndefined();
expect(dto.endDate).toBeUndefined();
});
+134 -193
View File
@@ -1,196 +1,151 @@
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 { 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 { AuthDto } from 'src/dtos/auth.dto';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
import { stringToBool } from 'src/validation';
import 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 AlbumUserAddSchema = z
.object({
userId: z.uuidv4().describe('User ID'),
role: AlbumUserRoleSchema.default(AlbumUserRole.Editor).optional().describe('Album user role'),
})
role?: AlbumUserRole;
}
.meta({ id: 'AlbumUserAddDto' });
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 AddUsersSchema = z
.object({
albumUsers: z.array(AlbumUserAddSchema).min(1).describe('Album users to add'),
})
shared?: boolean;
.meta({ id: 'AddUsersDto' });
@ValidateUUID({ optional: true, description: 'Filter albums containing this asset ID (ignores shared parameter)' })
assetId?: string;
}
const AlbumUserCreateSchema = z
.object({
userId: z.uuidv4().describe('User ID'),
role: AlbumUserRoleSchema,
})
.meta({ id: 'AlbumUserCreateDto' });
export class AlbumStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of owned albums' })
owned!: 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 shared albums' })
shared!: number;
const AlbumsAddAssetsSchema = z
.object({
albumIds: z.array(z.uuidv4()).describe('Album IDs'),
assetIds: z.array(z.uuidv4()).describe('Asset IDs'),
})
.meta({ id: 'AlbumsAddAssetsDto' });
@ApiProperty({ type: 'integer', description: 'Number of non-shared albums' })
notShared!: number;
}
const AlbumsAddAssetsResponseSchema = z
.object({
success: z.boolean().describe('Operation success'),
error: BulkIdErrorReasonSchema.optional(),
})
.meta({ id: 'AlbumsAddAssetsResponseDto' });
export class UpdateAlbumUserDto {
@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 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 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' });
export class ContributorCountResponseDto {
@ApiProperty({ description: 'User ID' })
userId!: string;
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' });
@ApiProperty({ type: 'integer', description: 'Number of assets contributed' })
assetCount!: number;
}
const UpdateAlbumUserSchema = z
.object({
role: AlbumUserRoleSchema,
})
.meta({ id: 'UpdateAlbumUserDto' });
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 AlbumUserResponseSchema = z
.object({
user: UserResponseSchema,
role: AlbumUserRoleSchema,
})
.meta({ id: 'AlbumUserResponseDto' });
// Description lives on schema to avoid duplication
@ApiPropertyOptional({ description: undefined })
@Type(() => ContributorCountResponseDto)
contributorCounts?: ContributorCountResponseDto[];
}
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'),
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)
.min(1)
.describe(
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
),
hasSharedLink: z.boolean().describe('Has shared link'),
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 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[];
@@ -202,17 +157,11 @@ export type MapAlbumDto = {
createdAt: Date;
updatedAt: Date;
id: string;
ownerId: string;
owner: ShallowDehydrateObject<User>;
isActivityEnabled: boolean;
order: AssetOrder;
};
export const mapAlbum = (
entity: MaybeDehydrated<MapAlbumDto>,
withAssets: boolean,
auth?: AuthDto,
): AlbumResponseDto => {
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = [];
if (entity.albumUsers) {
@@ -225,12 +174,10 @@ export const mapAlbum = (
}
}
const albumUsersSorted = _.orderBy(albumUsers, ['role', 'user.name']);
const assets = entity.assets || [];
const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
const hasSharedUser = albumUsers.length > 0;
const hasSharedUser = albumUsers.length > 1;
let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime;
@@ -246,19 +193,13 @@ export const mapAlbum = (
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
id: entity.id,
ownerId: entity.ownerId,
owner: mapUser(entity.owner),
albumUsers: albumUsersSorted,
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
startDate: asDateString(startDate),
endDate: asDateString(endDate),
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
order: entity.order,
};
};
export const mapAlbumWithAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, true);
export const mapAlbumWithoutAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, false);
+36 -49
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 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) {}
+37 -23
View File
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import 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',
@@ -26,17 +30,27 @@ export enum BulkIdErrorReason {
VALIDATION = 'validation',
}
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;
errorMessage?: string;
}
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(),
errorMessage: z.string().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) {}
+34 -29
View File
@@ -1,47 +1,52 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ValidateEnum } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import 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' });
export class AssetMediaResponseDto extends createZodDto(AssetMediaResponseSchema) {}
export class AssetBulkUploadCheckResponseDto extends createZodDto(AssetBulkUploadCheckResponseSchema) {}
+49 -103
View File
@@ -1,10 +1,9 @@
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 { HistoryBuilder } from 'src/decorators';
import { AssetMetadataUpsertItemSchema } from 'src/dtos/asset.dto';
import { AssetVisibilitySchema } from 'src/enum';
import { isoDatetimeToDate, JsonParsed, stringToBool } from 'src/validation';
import z from 'zod';
export enum AssetMediaSize {
Original = 'original',
@@ -17,13 +16,18 @@ 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().meta(
new HistoryBuilder()
.updated('v3', "Specifying 'original' is deprecated. Use the original endpoint directly instead")
.getExtensions(),
),
edited: stringToBool.default(false).optional().describe('Return edited asset if available'),
})
.meta({ id: 'AssetMediaOptionsDto' });
export enum UploadFieldName {
ASSET_DATA = 'assetData',
@@ -31,98 +35,40 @@ export enum UploadFieldName {
PROFILE_DATA = 'file',
}
class AssetMediaBase {
@ApiProperty({ description: 'Device asset ID' })
@IsNotEmpty()
@IsString()
deviceAssetId!: string;
const AssetMediaBaseSchema = z.object({
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;
@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 {}
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 AssetBulkUploadCheckDto extends createZodDto(AssetBulkUploadCheckSchema) {}
+111 -140
View File
@@ -1,144 +1,130 @@
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, ChecksumAlgorithm } 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,
ChecksumAlgorithm,
} 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 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().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
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.'),
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').beta('v2.5.0').getExtensions()),
}).shape,
).meta({ id: 'AssetResponseDto' });
export class AssetResponseDto extends createZodDto(AssetResponseSchema) {}
export type MapAsset = {
createdAt: Date;
@@ -149,8 +135,6 @@ export type MapAsset = {
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
@@ -180,17 +164,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;
@@ -248,7 +221,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
originalMimeType: mimeTypes.lookup(entity.originalFileName),
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
localDateTime: asDateString(entity.localDateTime),
duration: entity.duration ?? '0:00:00.00000',
duration: entity.duration,
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
width: entity.width,
@@ -262,10 +235,8 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
return {
id: entity.id,
createdAt: asDateString(entity.createdAt),
deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId,
owner: entity.owner ? mapUser(entity.owner) : undefined,
deviceId: entity.deviceId,
libraryId: entity.libraryId,
type: entity.type,
originalPath: entity.originalPath,
@@ -280,7 +251,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
isArchived: entity.visibility === AssetVisibility.Archive,
isTrashed: !!entity.deletedAt,
visibility: entity.visibility,
duration: entity.duration ?? '0:00:00.00000',
duration: entity.duration,
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map((tag) => mapTag(tag)),
+155 -231
View File
@@ -1,125 +1,66 @@
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 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 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'),
})
@Optional({ nullable: true })
@IsInt()
@Max(5)
@Min(-1)
@Transform(({ value }) => (value === 0 ? null : value))
rating?: number | null;
.refine(
(data) =>
(data.latitude === undefined && data.longitude === undefined) ||
(data.latitude !== undefined && data.longitude !== undefined),
{ message: 'Latitude and longitude must be provided together' },
);
@ApiProperty({ description: 'Asset description' })
@Optional()
@IsString()
description?: 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)'),
});
export class AssetBulkUpdateDto extends UpdateAssetBase {
@ValidateUUID({ each: true, description: 'Asset IDs to update' })
ids!: string[];
const AssetBulkUpdateSchema = AssetBulkUpdateBaseSchema.pipe(
IsNotSiblingOf(AssetBulkUpdateBaseSchema, 'dateTimeRelative', ['dateTimeOriginal']),
).meta({ id: 'AssetBulkUpdateDto' });
@ValidateString({ optional: true, nullable: true, description: 'Duplicate ID' })
duplicateId?: string | null;
const UpdateAssetSchema = UpdateAssetBaseSchema.extend({
livePhotoVideoId: z.uuidv4().nullish().describe('Live photo video ID'),
}).meta({ id: 'UpdateAssetDto' });
@ApiProperty({ description: 'Relative time offset in seconds' })
@IsNotSiblingOf(['dateTimeOriginal'])
@Optional()
@IsInt()
dateTimeRelative?: number;
const AssetBulkDeleteSchema = BulkIdsSchema.extend({
force: z.boolean().optional().describe('Force delete even if in use'),
}).meta({ id: 'AssetBulkDeleteDto' });
@ApiProperty({ description: 'Time zone (IANA timezone)' })
@IsNotSiblingOf(['dateTimeOriginal'])
@IsTimeZone()
@Optional()
timeZone?: string;
}
export class UpdateAssetDto extends UpdateAssetBase {
@ValidateUUID({ optional: true, nullable: true, description: 'Live photo video ID' })
livePhotoVideoId?: string | null;
}
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 +69,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 +175,19 @@ export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
total: Object.values(stats).reduce((total, value) => total + value, 0),
};
};
export class AssetBulkUpdateDto extends createZodDto(AssetBulkUpdateSchema) {}
export class UpdateAssetDto extends createZodDto(UpdateAssetSchema) {}
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) {}
+109 -140
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 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,100 @@ 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 OAuthBackchannelLogoutSchema = z
.object({ logout_token: z.string().describe('OAuth logout token') })
.meta({ id: 'OAuthBackchannelLogoutDto' });
@ApiPropertyOptional({ description: 'OAuth state parameter' })
@Optional()
@IsString()
state?: 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 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 OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}
+15 -23
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 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' });
+28 -18
View File
@@ -1,22 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
export class DatabaseBackupDto {
filename!: string;
filesize!: number;
timezone!: string;
}
const DatabaseBackupSchema = z
.object({
filename: z.string().describe('Backup filename'),
filesize: z.number().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'),
})
.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) {}
+30 -35
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 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) {}
+25 -31
View File
@@ -1,35 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ValidateUUID } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
import 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'),
suggestedKeepAssetIds: z.array(z.uuidv4()).describe('Suggested asset IDs to keep based on file size and EXIF data'),
})
.meta({ id: 'DuplicateResponseDto' });
@ValidateUUID({ each: true, description: 'Suggested asset IDs to keep based on file size and EXIF data' })
suggestedKeepAssetIds!: string[];
}
const DuplicateResolveGroupSchema = z
.object({
duplicateId: z.uuidv4(),
keepAssetIds: z.array(z.uuidv4()).describe('Asset IDs to keep'),
trashAssetIds: z.array(z.uuidv4()).describe('Asset IDs to trash or delete'),
})
.meta({ id: 'DuplicateResolveGroupDto' });
export class DuplicateResolveGroupDto {
@ValidateUUID()
duplicateId!: string;
const DuplicateResolveSchema = z
.object({
groups: z.array(DuplicateResolveGroupSchema).min(1).describe('List of duplicate groups to resolve'),
})
.meta({ id: 'DuplicateResolveDto' });
@ValidateUUID({ each: true, description: 'Asset IDs to keep' })
keepAssetIds!: string[];
@ValidateUUID({ each: true, description: 'Asset IDs to trash or delete' })
trashAssetIds!: string[];
}
export class DuplicateResolveDto {
@ApiProperty({ description: 'List of duplicate groups to resolve' })
@ValidateNested({ each: true })
@IsArray()
@Type(() => DuplicateResolveGroupDto)
@ArrayMinSize(1)
groups!: DuplicateResolveGroupDto[];
}
export class DuplicateResponseDto extends createZodDto(DuplicateResponseSchema) {}
export class DuplicateResolveGroupDto extends createZodDto(DuplicateResolveGroupSchema) {}
export class DuplicateResolveDto extends createZodDto(DuplicateResolveSchema) {}
+111 -88
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 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>;
+74 -209
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 z from 'zod';
// TODO import from sql-tools once the swagger plugin supports external enums
enum DatabaseSslMode {
@@ -12,214 +11,80 @@ 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;
@IsString()
@Optional()
IMMICH_HELMET_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_HELMET_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' });
+32 -60
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 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,
};
}
+10 -6
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 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) {}
+72 -121
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 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;
+9 -17
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) {}
+50 -42
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 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) {}
+40 -62
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 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) {}
+70 -121
View File
@@ -1,136 +1,85 @@
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, nonEmptyPartial, stringToBool } from 'src/validation';
import 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.int().min(1000).max(9999).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 = nonEmptyPartial({
isSaved: z.boolean().describe('Is memory saved'),
seenAt: isoDatetimeToDate.describe('Date when memory was seen'),
memoryAt: isoDatetimeToDate.describe('Memory date'),
}).meta({ id: 'MemoryUpdateDto' });
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()),
})
hideAt?: Date;
.meta({ id: 'MemoryCreateDto' });
@ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' })
assetIds?: string[];
}
const MemoryStatisticsResponseSchema = z
.object({
total: z.int().describe('Total number of memories'),
})
.meta({ id: 'MemoryStatisticsResponseDto' });
export class MemoryStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of memories' })
total!: number;
}
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 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[];
}
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 {
+49 -75
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 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) {}
+75 -101
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 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,
+20 -40
View File
@@ -1,42 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { createZodDto } from 'nestjs-zod';
import 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) {}
+7 -5
View File
@@ -1,8 +1,10 @@
import { ValidateBoolean } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import 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 {}
+30 -21
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 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) {}
+167 -213
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 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 {
+49 -121
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 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) {}
+49 -74
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 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;
+39 -71
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 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 {
+64 -70
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 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) {}
+189 -384
View File
@@ -1,282 +1,155 @@
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 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'),
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 people data in response'),
}).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'),
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 people data in response' })
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 +161,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) {}
+142 -222
View File
@@ -1,242 +1,162 @@
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 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 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: 'Total disk size in bytes' })
diskSizeRaw!: 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: 'Used disk space in bytes' })
diskUseRaw!: 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: 'integer', format: 'int64', description: 'Available disk space in bytes' })
diskAvailableRaw!: number;
@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 ServerConfigDto extends createZodDto(ServerConfigSchema) {}
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
export interface ReleaseNotification {
isAvailable: boolean;
+34 -48
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 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,
+75 -143
View File
@@ -1,155 +1,87 @@
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, mapAlbum } from 'src/dtos/album.dto';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkTypeSchema } from 'src/enum';
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
import 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 SharedLinkResponseSchema = z
.object({
id: z.string().describe('Shared link ID'),
description: z.string().nullable().describe('Link description'),
password: z.string().nullable().describe('Has password'),
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' });
@ApiProperty({ description: 'Custom URL slug' })
slug!: string | null;
}
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 SharedLinkResponseDto extends createZodDto(SharedLinkResponseSchema) {}
export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto {
const assets = sharedLink.assets || [];
@@ -164,7 +96,7 @@ export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetad
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
album: sharedLink.album ? mapAlbum(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
+31 -25
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 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);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+29 -22
View File
@@ -1,26 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ValidateBoolean } from 'src/validation';
import { createZodDto } from 'nestjs-zod';
import 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) {}
+50 -55
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 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 {
+121 -221
View File
@@ -1,230 +1,130 @@
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 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/gif durations in hh:mm:ss.SSS format (null for static 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) {}
+9 -5
View File
@@ -1,6 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { createZodDto } from 'nestjs-zod';
import 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) {}
+182 -272
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 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;
+12 -8
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 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) {}
+35 -45
View File
@@ -1,69 +1,59 @@
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);
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);
expect(result.data?.email).toEqual(someEmail);
});
});
+102 -184
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 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: new Date(license.activatedAt) } : null,
};
}
+72 -131
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 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 {
+127 -16
View File
@@ -1,3 +1,5 @@
import z from 'zod';
export enum AuthType {
Password = 'password',
OAuth = 'oauth',
@@ -37,9 +39,13 @@ export enum AssetType {
Other = 'OTHER',
}
export const AssetTypeSchema = z.enum(AssetType).describe('Asset type').meta({ id: 'AssetTypeEnum' });
export enum ChecksumAlgorithm {
sha1File = 'sha1', // sha1 checksum of the whole file contents
sha1Path = 'sha1-path', // sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated
/** sha1 checksum of the whole file contents */
sha1File = 'sha1',
/** sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated */
sha1Path = 'sha1-path',
}
export enum AssetFileType {
@@ -55,30 +61,26 @@ export enum AssetFileType {
export enum AlbumUserRole {
Editor = 'editor',
Owner = 'owner',
Viewer = 'viewer',
}
export const AlbumUserRoleSchema = z.enum(AlbumUserRole).describe('Album user role').meta({ id: 'AlbumUserRole' });
export enum AssetOrder {
Asc = 'asc',
Desc = 'desc',
}
export enum DatabaseAction {
Create = 'CREATE',
Update = 'UPDATE',
Delete = 'DELETE',
}
export enum EntityType {
Asset = 'ASSET',
Album = 'ALBUM',
}
export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' });
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,
@@ -87,6 +89,11 @@ export enum AssetOrderWithRandom {
Random = 'random',
}
export const AssetOrderWithRandomSchema = z
.enum(AssetOrderWithRandom)
.describe('Sort order')
.meta({ id: 'MemorySearchOrder' });
export enum Permission {
All = 'all',
@@ -110,7 +117,6 @@ export enum Permission {
AssetView = 'asset.view',
AssetDownload = 'asset.download',
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AssetDerive = 'asset.derive',
@@ -303,6 +309,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',
@@ -312,6 +320,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',
@@ -331,6 +341,11 @@ export enum UserMetadataKey {
Onboarding = 'onboarding',
}
export const UserMetadataKeySchema = z
.enum(UserMetadataKey)
.describe('User metadata key')
.meta({ id: 'UserMetadataKey' });
export enum AssetMetadataKey {
MobileApp = 'mobile-app',
}
@@ -348,12 +363,19 @@ 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',
@@ -366,6 +388,8 @@ export enum SourceType {
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',
@@ -375,6 +399,8 @@ 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',
@@ -398,6 +424,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',
@@ -412,6 +443,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',
@@ -421,6 +454,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',
@@ -428,6 +463,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',
@@ -436,6 +476,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',
@@ -443,22 +488,30 @@ 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',
@@ -473,11 +526,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',
@@ -487,8 +544,6 @@ export enum ApiCustomExtension {
export enum MetadataKey {
AuthRoute = 'auth_route',
AdminRoute = 'admin_route',
SharedRoute = 'shared_route',
ApiKeySecurity = 'api_key',
EventConfig = 'event_config',
JobConfig = 'job_config',
@@ -512,6 +567,11 @@ export enum ImmichEnvironment {
Production = 'production',
}
export const ImmichEnvironmentSchema = z
.enum(ImmichEnvironment)
.describe('Immich environment')
.meta({ id: 'ImmichEnvironment' });
export enum ImmichWorker {
Api = 'api',
Maintenance = 'maintenance',
@@ -577,6 +637,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',
@@ -586,6 +648,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',
@@ -603,7 +667,6 @@ export enum JobName {
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
AuditLogCleanup = 'AuditLogCleanup',
AuditTableCleanup = 'AuditTableCleanup',
DatabaseBackup = 'DatabaseBackup',
@@ -666,6 +729,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 */
@@ -678,6 +743,11 @@ 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',
@@ -717,12 +787,18 @@ export enum MaintenanceAction {
RestoreDatabase = 'restore_database',
}
export const MaintenanceActionSchema = z
.enum(MaintenanceAction)
.describe('Maintenance action')
.meta({ id: 'MaintenanceAction' });
export enum ExitCode {
AppRestart = 7,
}
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumsV2 = 'AlbumsV2',
AlbumUsersV1 = 'AlbumUsersV1',
AlbumToAssetsV1 = 'AlbumToAssetsV1',
AlbumAssetsV1 = 'AlbumAssetsV1',
@@ -747,6 +823,11 @@ export enum SyncRequestType {
UserMetadataV1 = 'UserMetadataV1',
}
export const SyncRequestTypeSchema = z
.enum(SyncRequestType)
.describe('Sync request type')
.meta({ id: 'SyncRequestType' });
export enum SyncEntityType {
AuthUserV1 = 'AuthUserV1',
@@ -776,6 +857,7 @@ export enum SyncEntityType {
PartnerStackV1 = 'PartnerStackV1',
AlbumV1 = 'AlbumV1',
AlbumV2 = 'AlbumV2',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',
@@ -817,6 +899,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',
@@ -824,6 +908,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',
@@ -833,11 +922,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',
@@ -849,6 +948,11 @@ export enum AssetVisibility {
Locked = 'locked',
}
export const AssetVisibilitySchema = z
.enum(AssetVisibility)
.describe('Asset visibility')
.meta({ id: 'AssetVisibility' });
export enum CronJob {
LibraryScan = 'LibraryScan',
NightlyJobs = 'NightlyJobs',
@@ -901,7 +1005,14 @@ export enum PluginContext {
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' });
@@ -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 };
}
+15 -17
View File
@@ -14,27 +14,26 @@ select
"activity"."id"
from
"activity"
left join "album" on "activity"."albumId" = "album"."id"
inner join "album" on "activity"."albumId" = "album"."id"
and "album"."deletedAt" is null
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1::uuid
where
"activity"."id" in ($1)
and "album"."ownerId" = $2::uuid
"activity"."id" in ($2)
-- AccessRepository.activity.checkCreateAccess
select
"album"."id"
from
"album"
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."userId"
inner join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
inner join "user" on "user"."id" = "albumUsers"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
and "album"."isActivityEnabled" = $2
and (
"album"."ownerId" = $3
or "user"."id" = $4
)
and "user"."id" = $3
and "album"."deletedAt" is null
-- AccessRepository.album.checkOwnerAccess
@@ -42,9 +41,11 @@ select
"album"."id"
from
"album"
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1
where
"album"."id" in ($1)
and "album"."ownerId" = $2
"album"."id" in ($2)
and "album"."deletedAt" is null
-- AccessRepository.album.checkSharedAlbumAccess
@@ -52,8 +53,8 @@ select
"album"."id"
from
"album"
left join "album_user" on "album_user"."albumId" = "album"."id"
left join "user" on "user"."id" = "album_user"."userId"
inner join "album_user" on "album_user"."albumId" = "album"."id"
inner join "user" on "user"."id" = "album_user"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
@@ -93,10 +94,7 @@ where
"asset"."id" = any (target.ids)
or "asset"."livePhotoVideoId" = any (target.ids)
)
and (
"album"."ownerId" = $2
or "user"."id" = $3
)
and "user"."id" = $2
and "album"."deletedAt" is null
-- AccessRepository.asset.checkOwnerAccess
+253 -146
View File
@@ -1,26 +1,17 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AlbumRepository.getById
with
"album_user" as (
select
*
from
"album_user"
where
"album_user"."albumId" = $1
)
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -41,15 +32,21 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $2 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
@@ -88,30 +85,12 @@ select
from
"album"
where
"album"."id" = $1
"album"."id" = $3
and "album"."deletedAt" is null
-- AlbumRepository.getByAssetId
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -132,36 +111,38 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
and "album_asset"."assetId" = $3
and "album"."deletedAt" is null
order by
"album"."createdAt" desc,
"album"."createdAt" desc
-- AlbumRepository.getByAssetIds
@@ -172,18 +153,15 @@ from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $1
)
and "album_asset"."assetId" in ($3)
and "album_asset"."assetId" in ($2)
and "album"."deletedAt" is null
-- AlbumRepository.getMetadataForIds
@@ -210,24 +188,6 @@ group by
-- AlbumRepository.getOwned
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -248,15 +208,21 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
@@ -274,9 +240,11 @@ select
) as "sharedLinks"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
"album"."deletedAt" is null
order by
"album"."createdAt" desc
@@ -303,35 +271,23 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -347,29 +303,34 @@ select
) as "sharedLinks"
from
"album"
inner join (
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $2
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."role" != 'owner'
)
union
select
"shared_link"."albumId" as "id"
from
"shared_link"
where
"shared_link"."userId" = $3
and "shared_link"."albumId" is not null
) as "matching" on "matching"."id" = "album"."id"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."role" = 'owner'
where
(
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and (
"album"."ownerId" = $1
or "album_user"."userId" = $2
)
)
or exists (
select
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
and "shared_link"."userId" = $3
)
)
and "album"."deletedAt" is null
"album"."deletedAt" is null
order by
"album"."createdAt" desc
@@ -378,33 +339,68 @@ select
"album".*,
(
select
to_json(obj)
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"shared_link".*
from
"user"
"shared_link"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner"
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
"album"."deletedAt" is null
and not exists (
select
from
"album_user"
"album_user" as "au"
where
"album_user"."albumId" = "album"."id"
"au"."albumId" = "album"."id"
and "au"."role" != 'owner'
)
and not exists (
select
@@ -430,6 +426,117 @@ where
"album_asset"."albumId" = $1
and "album_asset"."assetId" in ($2)
-- AlbumRepository.addAssetIds
insert into
"album_asset"
select
$1::uuid as "albumId",
unnest($2::uuid[]) as "assetId"
from
(
select
1
) as "dummy"
on conflict do nothing
-- AlbumRepository.create
with
"album" as (
insert into
"album" ("albumName")
values
($1)
returning
*
),
"album_user" as (
insert into
"album_user"
select
"album"."id" as "albumId",
unnest($2::uuid[]) as "userId",
unnest($3::album_user_role_enum[]) as "role"
from
"album"
returning
"album_user"."albumId",
"album_user"."userId",
"album_user"."role"
),
"album_asset" as (
insert into
"album_asset"
select
"album"."id" as "albumId",
unnest($4::uuid[]) as "assetId"
from
"album"
on conflict do nothing
returning
"album_asset"."albumId",
"album_asset"."assetId"
)
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"user"."name" asc
) as agg
) as "albumUsers",
(
select
json_agg("asset") as "assets"
from
(
select
"asset".*,
"asset_exif" as "exifInfo"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"album_asset"."albumId" = "album"."id"
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline')
order by
"asset"."fileCreatedAt" desc
) as "asset"
) as "assets"
from
"album"
-- AlbumRepository.getContributorCounts
select
"asset"."ownerId" as "userId",
+1 -2
View File
@@ -163,6 +163,7 @@ where
'%.jp2',
'%.jpe',
'%.jxl',
'%.mpo',
'%.svg',
'%.tif',
'%.tiff'
@@ -250,8 +251,6 @@ select
"asset"."id",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."deviceAssetId",
"asset"."deviceId",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
+1 -70
View File
@@ -242,17 +242,6 @@ where
limit
$3
-- AssetRepository.getAllByDeviceId
select
"deviceAssetId"
from
"asset"
where
"ownerId" = $1::uuid
and "deviceId" = $2
and "visibility" != $3
and "deletedAt" is null
-- AssetRepository.getLivePhotoCount
select
count(*) as "count"
@@ -312,9 +301,8 @@ limit
-- AssetRepository.updateAll
update "asset"
set
"deviceId" = $1
where
"id" = any ($2::uuid[])
"id" = any ($1::uuid[])
-- AssetRepository.getByChecksum
select
@@ -497,63 +485,6 @@ where
limit
$5
-- AssetRepository.getAllForUserFullSync
select
"asset".*,
to_json("asset_exif") as "exifInfo",
to_json("stacked_assets") as "stack"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "stack" on "stack"."id" = "asset"."stackId"
left join lateral (
select
"stack".*,
count("stacked") as "assetCount"
from
"asset" as "stacked"
where
"stacked"."stackId" = "stack"."id"
group by
"stack"."id"
) as "stacked_assets" on "stack"."id" is not null
where
"asset"."ownerId" = $1::uuid
and "asset"."visibility" != $2
and "asset"."updatedAt" <= $3
and "asset"."id" > $4
order by
"asset"."id"
limit
$5
-- AssetRepository.getChangedDeltaSync
select
"asset".*,
to_json("asset_exif") as "exifInfo",
to_json("stacked_assets") as "stack"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "stack" on "stack"."id" = "asset"."stackId"
left join lateral (
select
"stack".*,
count("stacked") as "assetCount"
from
"asset" as "stacked"
where
"stacked"."stackId" = "stack"."id"
group by
"stack"."id"
) as "stacked_assets" on "stack"."id" is not null
where
"asset"."ownerId" = any ($1::uuid[])
and "asset"."visibility" != $2
and "asset"."updatedAt" > $3
limit
$4
-- AssetRepository.detectOfflineExternalAssets
update "asset"
set
-16
View File
@@ -1,16 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AuditRepository.getAfter
select distinct
on ("audit"."entityId", "audit"."entityType") "audit"."entityId"
from
"audit"
where
"audit"."createdAt" > $1
and "audit"."action" = $2
and "audit"."entityType" = $3
and "audit"."ownerId" in ($4)
order by
"audit"."entityId" desc,
"audit"."entityType" desc,
"audit"."createdAt" desc
+22 -2
View File
@@ -1,5 +1,25 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MapRepository.getAlbumMapMarkers
select
"id",
"asset_exif"."latitude" as "lat",
"asset_exif"."longitude" as "lon",
"asset_exif"."city",
"asset_exif"."state",
"asset_exif"."country"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
and "asset_exif"."latitude" is not null
and "asset_exif"."longitude" is not null
inner join "album_asset" on "asset"."id" = "album_asset"."assetId"
where
"asset"."deletedAt" is null
and "album_asset"."albumId" = $1
order by
"fileCreatedAt" desc
-- MapRepository.getMapMarkers
select
"id",
@@ -14,8 +34,8 @@ from
and "asset_exif"."latitude" is not null
and "asset_exif"."longitude" is not null
where
"asset"."visibility" = $1
and "deletedAt" is null
"asset"."deletedAt" is null
and "asset"."visibility" = $1
and (
"ownerId" in ($2)
or exists (
+15 -40
View File
@@ -35,47 +35,22 @@ where
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
(
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
and "asset"."id" < $6
order by
random()
limit
$7
)
union all
(
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $8
and "asset"."fileCreatedAt" >= $9
and "asset_exif"."lensModel" = $10
and "asset"."ownerId" = any ($11::uuid[])
and "asset"."isFavorite" = $12
and "asset"."deletedAt" is null
and "asset"."id" > $13
order by
random()
limit
$14
)
select
"asset".*
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
random()
limit
$15
$6
-- SearchRepository.searchLargeAssets
select
+1 -1
View File
@@ -74,7 +74,7 @@ delete from "session"
where
"id" = $1::uuid
-- SessionRepository.invalidate
-- SessionRepository.invalidateAll
delete from "session"
where
"userId" = $1
+18 -2
View File
@@ -139,7 +139,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where
@@ -201,7 +209,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where
+28 -69
View File
@@ -29,7 +29,6 @@ order by
-- SyncRepository.album.getUpserts
select distinct
on ("album"."id", "album"."updateId") "album"."id",
"album"."ownerId",
"album"."albumName" as "name",
"album"."description",
"album"."createdAt",
@@ -44,13 +43,19 @@ from
where
"album"."updateId" < $1
and "album"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_users"."userId" = $4
)
and "album_users"."userId" = $3
order by
"album"."updateId" asc
-- SyncRepository.album.getAlbumUsers
select
"userId",
"role"
from
"album_user"
where
"albumId" = $1
-- SyncRepository.albumAsset.getBackfill
select
"asset"."id",
@@ -109,16 +114,12 @@ select
from
"asset" as "asset"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset"."updateId" asc
@@ -147,15 +148,11 @@ select
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -229,16 +226,12 @@ select
from
"asset_exif" as "asset_exif"
inner join "album_asset" on "album_asset"."assetId" = "asset_exif"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset_exif"."updateId" asc
@@ -278,10 +271,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -312,20 +302,11 @@ where
and "album_asset_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_asset_audit"."id" asc
@@ -337,15 +318,11 @@ select
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -377,20 +354,11 @@ where
and "album_user_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_user_audit"."id" asc
@@ -408,20 +376,11 @@ where
and "album_user"."updateId" > $2
and "album_user"."albumId" in (
select
"id"
"albumUsers"."albumId" as "id"
from
"album"
"album_user" as "albumUsers"
where
"ownerId" = $3
union
(
select
"albumUsers"."albumId" as "id"
from
"album_user" as "albumUsers"
where
"albumUsers"."userId" = $4
)
"albumUsers"."userId" = $3
)
order by
"album_user"."updateId" asc
+19 -9
View File
@@ -35,9 +35,14 @@ class ActivityAccess {
return this.db
.selectFrom('activity')
.select('activity.id')
.leftJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', asUuid(userId)),
)
.where('activity.id', 'in', [...activityIds])
.whereRef('album.ownerId', '=', asUuid(userId))
.execute()
.then((activities) => new Set(activities.map((activity) => activity.id)));
}
@@ -52,11 +57,11 @@ class ActivityAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.isActivityEnabled', '=', true)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where((eb) => eb('user.id', '=', userId))
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@@ -77,7 +82,12 @@ class AlbumAccess {
.selectFrom('album')
.select('album.id')
.where('album.id', 'in', [...albumIds])
.where('album.ownerId', '=', userId)
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', userId),
)
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@@ -96,8 +106,8 @@ class AlbumAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user', 'album_user.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user', 'album_user.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.deletedAt', 'is', null)
.where('user.id', '=', userId)
@@ -152,7 +162,7 @@ class AssetAccess {
eb('asset.livePhotoVideoId', '=', sql<string>`any(target.ids)`),
]),
)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where('user.id', '=', userId)
.where('album.deletedAt', 'is', null)
.execute()
.then((assets) => {
@@ -7,7 +7,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { asUuid } from 'src/utils/database';
import { asUuid, dummy } from 'src/utils/database';
export interface ActivitySearch {
albumId?: string;
@@ -31,11 +31,7 @@ export class ActivityRepository {
join.onRef('user2.id', '=', 'activity.userId').on('user2.deletedAt', 'is', null),
)
.innerJoinLateral(
(eb) =>
eb
.selectFrom(sql`(select 1)`.as('dummy'))
.select(columns.userWithPrefix)
.as('user'),
(eb) => eb.selectFrom(dummy).select(columns.userWithPrefix).as('user'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))
+167 -121
View File
@@ -14,10 +14,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { withDefaultVisibility } from 'src/utils/database';
import { asUuid, dummy, withDefaultVisibility } from 'src/utils/database';
export interface AlbumAssetCount {
albumId: string;
@@ -31,33 +32,25 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
const withOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
.$notNull()
.as('owner');
};
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId'))
.$notNull()
.as('user'),
)
.whereRef('album_user.albumId', '=', 'album.id'),
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
.orderBy('user.name', 'asc'),
)
.$notNull()
.as('albumUsers');
};
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
).as('sharedLinks');
};
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
@@ -80,19 +73,28 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
.as('assets');
};
const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.role', '=', AlbumUserRole.Owner)
.where('album_user.userId', '=', ownerId),
);
@Injectable()
export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
async getById(id: string, options: AlbumInfoOptions) {
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
getById(id: string, options: AlbumInfoOptions, authUserId?: string) {
return this.db
.with('album_user', (qb) => qb.selectFrom('album_user').selectAll().where('album_user.albumId', '=', id))
.selectFrom('album')
.selectAll('album')
.where('album.id', '=', id)
.where('album.deletedAt', 'is', null)
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(authUserId))
.select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets))
.$narrowType<{ assets: NotNull }>()
@@ -100,27 +102,22 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string) {
getByAssetId(ownerId: string, assetId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', '=', assetId)
.where('album.deletedAt', 'is', null)
.orderBy('album.createdAt', 'desc')
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
@@ -137,15 +134,12 @@ export class AlbumRepository {
.select('album.id')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', 'in', assetIds)
.where('album.deletedAt', 'is', null)
@@ -190,15 +184,19 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getOwned(ownerId: string) {
getOwned(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.select(withOwner)
.select(withAlbumUsers)
.select(withSharedLink)
.where('album.ownerId', '=', ownerId)
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
@@ -207,29 +205,40 @@ export class AlbumRepository {
* Get albums shared with and shared by owner.
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getShared(ownerId: string) {
getShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where((eb) =>
eb.or([
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
),
eb.exists(
eb
.selectFrom('shared_link')
.whereRef('shared_link.albumId', '=', 'album.id')
.where('shared_link.userId', '=', ownerId),
),
]),
.innerJoin(
(eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId as id')
.where('album_user.userId', '=', ownerId)
.where(
'album_user.albumId',
'in',
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)),
)
.union(
eb
.selectFrom('shared_link')
.where('shared_link.userId', '=', ownerId)
.where('shared_link.albumId', 'is not', null)
.select('shared_link.albumId as id')
.$narrowType<{ id: NotNull }>(),
)
.as('matching'),
(join) => join.onRef('matching.id', '=', 'album.id'),
)
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers)
.select(withOwner)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
@@ -239,29 +248,45 @@ export class AlbumRepository {
* Get albums of owner that are _not_ shared
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getNotShared(ownerId: string) {
getNotShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where('album.ownerId', '=', ownerId)
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
.select(withOwner)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
),
),
)
.where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
)
.select(withSharedLink)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
async restoreAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: null }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: null }).where(isAlbumOwned(userId)).execute();
}
async softDeleteAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: new Date() }).where(isAlbumOwned(userId)).execute();
}
async deleteAll(userId: string): Promise<void> {
await this.db.deleteFrom('album').where('ownerId', '=', userId).execute();
await this.db.deleteFrom('album').where(isAlbumOwned(userId)).execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@@ -306,52 +331,86 @@ export class AlbumRepository {
.then((results) => new Set(results.map(({ assetId }) => assetId)));
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.addAssets(this.db, albumId, assetIds);
if (assetIds.length === 0) {
return;
}
await this.db
.insertInto('album_asset')
.expression((eb) =>
eb.selectFrom(dummy).select([asUuid(albumId).as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
return this.db.transaction().execute(async (tx) => {
const newAlbum = await tx.insertInto('album').values(album).returning('album.id').executeTakeFirst();
@GenerateSql({
params: [
{ albumName: DummyValue.STRING },
[],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID],
],
})
async create(
album: Insertable<AlbumTable>,
assetIds: string[],
albumUsers: AlbumUserCreateDto[],
authUserId: string,
) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
throw new Error('Album must have an owner');
}
if (!newAlbum) {
throw new Error('Failed to create album');
}
const userIds = albumUsers.map((u) => u.userId);
const roles = albumUsers.map((u) => u.role);
if (assetIds.length > 0) {
await this.addAssets(tx, newAlbum.id, assetIds);
}
if (albumUsers.length > 0) {
await tx
const result = await this.db
.with('album', (db) => db.insertInto('album').values(album).returningAll())
.with('album_user', (db) =>
db
.insertInto('album_user')
.values(
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [
ref('album.id').as('albumId'),
sql`unnest(${userIds}::uuid[])`.as('userId'),
sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
]),
)
.execute();
}
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
)
.with('album_asset', (db) =>
db
.insertInto('album_asset')
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [ref('album.id').as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.returning(['album_asset.albumId', 'album_asset.assetId']),
)
.selectFrom('album')
.selectAll('album')
.select(withAlbumUsers(authUserId))
.select(withAssets)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
return tx
.selectFrom('album')
.selectAll('album')
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withAssets)
.select(withAlbumUsers)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
});
return result;
}
update(id: string, album: Updateable<AlbumTable>) {
update(id: string, album: Updateable<AlbumTable>, authUserId: string) {
return this.db
.updateTable('album')
.set(album)
.where('id', '=', id)
.where('album.id', '=', id)
.returningAll('album')
.returning(withOwner)
.returning(withSharedLink)
.returning(withAlbumUsers)
.returning(withAlbumUsers(authUserId))
.executeTakeFirstOrThrow();
}
@@ -359,19 +418,6 @@ export class AlbumRepository {
await this.db.deleteFrom('album').where('id', '=', id).execute();
}
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute();
}
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
if (values.length === 0) {
@@ -402,7 +448,7 @@ export class AlbumRepository {
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
.select('album_asset.assetId')
.orderBy('asset.fileCreatedAt', 'desc')
.limit(1),
.limit(sql.lit(1)),
}))
.where((eb) =>
eb.or([
+2 -125
View File
@@ -106,19 +106,6 @@ interface AssetExploreFieldOptions {
minAssetsPerField: number;
}
interface AssetFullSyncOptions {
ownerId: string;
lastId?: string;
updatedUntil: Date;
limit: number;
}
interface AssetDeltaSyncOptions {
userIds: string[];
updatedAfter: Date;
limit: number;
}
interface AssetGetByChecksumOptions {
ownerId: string;
checksum: Buffer;
@@ -318,7 +305,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 [];
}
@@ -461,18 +448,6 @@ export class AssetRepository {
await this.db.deleteFrom('asset').where('ownerId', '=', ownerId).execute();
}
async getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]> {
const assets = await this.db
.selectFrom('asset')
.select(['deviceAssetId'])
.where('deviceAssetId', 'in', deviceAssetIds)
.where('deviceId', '=', deviceId)
.where('ownerId', '=', asUuid(ownerId))
.execute();
return assets.map((asset) => asset.deviceAssetId);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
return this.db
@@ -484,27 +459,6 @@ export class AssetRepository {
.executeTakeFirst();
}
/**
* Get assets by device's Id on the database
* @param ownerId
* @param deviceId
*
* @returns Promise<string[]> - Array of assetIds belong to the device
*/
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
const items = await this.db
.selectFrom('asset')
.select(['deviceAssetId'])
.where('ownerId', '=', asUuid(ownerId))
.where('deviceId', '=', deviceId)
.where('visibility', '!=', AssetVisibility.Hidden)
.where('deletedAt', 'is', null)
.execute();
return items.map((asset) => asset.deviceAssetId);
}
@GenerateSql({ params: [DummyValue.UUID] })
async getLivePhotoCount(motionId: string): Promise<number> {
const [{ count }] = await this.db
@@ -581,7 +535,7 @@ export class AssetRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
@GenerateSql({ params: [[DummyValue.UUID], {}] })
@Chunked()
async updateAll(ids: string[], options: Updateable<AssetTable>): Promise<void> {
if (ids.length === 0) {
@@ -681,19 +635,6 @@ export class AssetRepository {
.executeTakeFirstOrThrow();
}
getRandom(userIds: string[], take: number) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.$call(withExif)
.$call(withDefaultVisibility)
.where('ownerId', '=', anyUuid(userIds))
.where('deletedAt', 'is', null)
.orderBy((eb) => eb.fn('random'))
.limit(take)
.execute();
}
@GenerateSql({ params: [{}] })
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
return this.db
@@ -918,70 +859,6 @@ export class AssetRepository {
return { fieldName: 'exifInfo.city', items };
}
@GenerateSql({
params: [
{
ownerId: DummyValue.UUID,
lastId: DummyValue.UUID,
updatedUntil: DummyValue.DATE,
limit: 10,
},
],
})
getAllForUserFullSync(options: AssetFullSyncOptions) {
const { ownerId, lastId, updatedUntil, limit } = options;
return this.db
.selectFrom('asset')
.selectAll('asset')
.$call(withExif)
.leftJoin('stack', 'stack.id', 'asset.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset as stacked')
.selectAll('stack')
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
.whereRef('stacked.stackId', '=', 'stack.id')
.groupBy('stack.id')
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack'))
.where('asset.ownerId', '=', asUuid(ownerId))
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.updatedAt', '<=', updatedUntil)
.$if(!!lastId, (qb) => qb.where('asset.id', '>', lastId!))
.orderBy('asset.id')
.limit(limit)
.execute();
}
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE, limit: 100 }] })
async getChangedDeltaSync(options: AssetDeltaSyncOptions) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.$call(withExif)
.leftJoin('stack', 'stack.id', 'asset.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset as stacked')
.selectAll('stack')
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
.whereRef('stacked.stackId', '=', 'stack.id')
.groupBy('stack.id')
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
.where('asset.ownerId', '=', anyUuid(options.userIds))
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.updatedAt', '>', options.updatedAfter)
.limit(options.limit)
.execute();
}
async upsertFile(
file: Pick<
Insertable<AssetFileTable>,
@@ -1,44 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DatabaseAction, EntityType } from 'src/enum';
import { DB } from 'src/schema';
export interface AuditSearch {
action?: DatabaseAction;
entityType?: EntityType;
userIds: string[];
}
@Injectable()
export class AuditRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({
params: [
DummyValue.DATE,
{ action: DatabaseAction.Create, entityType: EntityType.Asset, userIds: [DummyValue.UUID] },
],
})
async getAfter(since: Date, options: AuditSearch): Promise<string[]> {
const records = await this.db
.selectFrom('audit')
.where('audit.createdAt', '>', since)
.$if(!!options.action, (qb) => qb.where('audit.action', '=', options.action!))
.$if(!!options.entityType, (qb) => qb.where('audit.entityType', '=', options.entityType!))
.where('audit.ownerId', 'in', options.userIds)
.distinctOn(['audit.entityId', 'audit.entityType'])
.orderBy('audit.entityId', 'desc')
.orderBy('audit.entityType', 'desc')
.orderBy('audit.createdAt', 'desc')
.select('audit.entityId')
.execute();
return records.map(({ entityId }) => entityId);
}
async removeBefore(before: Date): Promise<void> {
await this.db.deleteFrom('audit').where('createdAt', '<', before).execute();
}
}

Some files were not shown because too many files have changed in this diff Show More